diff --git a/.changeset/invalid-entity-structured-issues.md b/.changeset/invalid-entity-structured-issues.md new file mode 100644 index 0000000..9ddaeaf --- /dev/null +++ b/.changeset/invalid-entity-structured-issues.md @@ -0,0 +1,15 @@ +--- +"@btravstack/entity": minor +--- + +**BREAKING**: `InvalidEntity.issues` is now `SchemaIssues` (Standard Schema +issues) instead of `readonly string[]`. + +Schema failures keep the `path` of the field that failed, so a caller can key a +field-level error response off it instead of parsing a rendered string. An +`invariants` violation has no `path` — the absence distinguishes a whole-entity +rule from a field complaint. Through `instance`, paths now compose with the +nested entity's position (`["owner", "slug"]`). + +Migration: `e.issues` yields objects, not strings — use `i.message`, and +`i.path` where you want the field. diff --git a/README.md b/README.md index e95dc37..bf7cb43 100644 --- a/README.md +++ b/README.md @@ -411,21 +411,39 @@ Every fallible entry point returns `Result`: ```ts class InvalidEntity extends TaggedError("InvalidEntity")<{ readonly entity: string; - readonly issues: readonly string[]; + readonly issues: SchemaIssues; // readonly StandardSchemaV1.Issue[] }> {} ``` -| Failure | Channel | Why | -| --------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------- | -| schema validation (a field fails its own zod check) | `InvalidEntity` | bad input, expected | -| a broken `invariants` rule | `InvalidEntity` | bad input, expected | -| `add`'s output failing its own declared schema | **defect** | `add` is pure, total, and typed — a violation is a bug in domain code, not bad caller input | -| any of the above, reached through `instance` | zod issues, path-prefixed | so a nested entity's failure names the member that failed | +| Failure | Channel | Why | +| --------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------- | +| schema validation (a field fails its own zod check) | `InvalidEntity`, issue has a `path` | bad input, expected | +| a broken `invariants` rule | `InvalidEntity`, issue has no `path` | bad input, expected — the rule spans the entity, not one field | +| `add`'s output failing its own declared schema | **defect** | `add` is pure, total, and typed — a violation is a bug in domain code, not bad caller input | +| any of the above, reached through `instance` | zod issues, paths composed | a nested field failure reports the full path | + +`issues` is carried **structured**, exactly as the validator produced it, not +rendered into prose — so keying a field-level error response is a `path` +lookup rather than a string parse. A schema issue has the failing field's +path (`["tags", 0]`, `["address", "city"]`); an `invariants` violation has +none, which is what distinguishes a whole-entity rule from a field complaint. ```ts ApiKey.decode({ ...raw, secret: "short" }); -// Err(InvalidEntity { entity: "ApiKey", issues: ["Too small: expected string to have >=16 characters"] }) - +// Err(InvalidEntity { +// entity: "ApiKey", +// issues: [{ path: ["secret"], message: "Too small: expected string to have >=16 characters" }], +// }) + +Trial.decode(brokenRow); +// Err(InvalidEntity { +// entity: "Trial", +// issues: [{ message: "trialEndsAt must be after createdAt" }], // an invariant: no path +// }) + +// through `instance`, paths compose with the position of the nested entity: +z.object({ owner: Organization.instance }).safeParse({ owner: { slug: "" } }); +// issues: [{ path: ["owner", "slug"], message: "Too small: …" }] z.object({ owner: Organization.instance }).safeParse({ owner: brokenRow }); // issues: [{ path: ["owner"], message: "trialEndsAt must be after createdAt" }] ``` diff --git a/packages/entity/README.md b/packages/entity/README.md index 4e7f557..5f4837f 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -35,7 +35,9 @@ org.equals(other); // equal encoded data Every fallible entry point (`decode`, `make`, `create`, `update`) returns an `unthrown` `Result` — call `.getOrThrow()`, `.match()`, or any other `Result` combinator on it, per this library's error-as-values -convention. See the [root README](../../README.md) for the full guide, +convention. `InvalidEntity.issues` is `SchemaIssues` — Standard Schema issues, +kept structured: a **schema** issue carries the failing field's `path`, an +`invariants` message has none. See the [root README](../../README.md) for the full guide, including the `decoded: { omit, add }` split, unions, and the lifecycle in a hexagonal architecture. diff --git a/packages/entity/src/crud.spec.ts b/packages/entity/src/crud.spec.ts index 13c5a97..840d7fc 100644 --- a/packages/entity/src/crud.spec.ts +++ b/packages/entity/src/crud.spec.ts @@ -83,7 +83,7 @@ test("update re-runs invariants", () => { const org = Organization.create(input, generated).getOrThrow(); const issues = org.update({ trialEndsAt: "2026-01-01T09:00:00Z" as never }).match({ ok: () => [] as readonly string[], - errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => i.message)), defect: () => ["DEFECT"], }); expect(issues).toEqual(["trialEndsAt must be after createdAt"]); diff --git a/packages/entity/src/decoded.spec.ts b/packages/entity/src/decoded.spec.ts index 35204d2..b5fb0fa 100644 --- a/packages/entity/src/decoded.spec.ts +++ b/packages/entity/src/decoded.spec.ts @@ -3,6 +3,7 @@ import { expect, test } from "vitest"; import { z } from "zod"; import { Entity, add } from "./index.js"; +import { keysOf } from "./issues.js"; const ApiKeyId = z.uuid().brand("ApiKeyId"); const OrgId = z.uuid().brand("OrgId"); @@ -48,6 +49,18 @@ test("decode does not round-trip through encode for a split entity", () => { expect(ApiKey.make(key.encode()).isOk()).toBe(true); }); +type Flat = { readonly path: readonly PropertyKey[]; readonly message: string }; + +const issuesOf = (r: ReturnType): readonly Flat[] => + r.match({ + ok: () => [{ path: [], message: "WRONGLY ACCEPTED" }], + errCases: (m) => + m.with(P.tag("InvalidEntity"), (e) => + e.issues.map((i) => ({ path: keysOf(i), message: i.message })), + ), + defect: () => [{ path: [], message: "DEFECT" }], + }); + test("bad caller input is InvalidEntity, never a defect", () => { const outcome = ApiKey.decode({ ...raw, secret: "short" }).match({ ok: () => "WRONGLY ACCEPTED", @@ -57,6 +70,24 @@ test("bad caller input is InvalidEntity, never a defect", () => { expect(outcome).toBe("invalid"); }); +test("a single bad field is named by its path", () => { + expect(issuesOf(ApiKey.decode({ ...raw, secret: "short" }))).toEqual([ + { path: ["secret"], message: "Too small: expected string to have >=16 characters" }, + ]); +}); + +test("each bad field is named when several fail at once", () => { + expect(issuesOf(ApiKey.decode({ ...raw, orgId: "nope", secret: "short" }))).toEqual([ + { path: ["orgId"], message: "Invalid UUID" }, + { path: ["secret"], message: "Too small: expected string to have >=16 characters" }, + ]); +}); + +test("an omitted field still reports under its wire name", () => { + // `secret` never reaches `decoded`, but `decode` validates `encoded` + expect(issuesOf(ApiKey.decode({ ...raw, secret: "short" }))[0]?.path).toEqual(["secret"]); +}); + test("add producing data its own schema rejects is a defect", () => { class Broken extends Entity("Broken")( { id: ApiKeyId, secret: Secret }, @@ -73,9 +104,13 @@ test("add producing data its own schema rejects is a defect", () => { const outcome = Broken.decode({ id: raw.id, secret: raw.secret }).match({ ok: () => "WRONGLY ACCEPTED", errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), - defect: () => "defect", + defect: (cause) => (cause instanceof Error ? cause.message : "defect"), }); - expect(outcome).toBe("defect"); + // the defect message carries the same path prefix, so a bug in `add` says + // which computed field its own schema rejected + expect(outcome).toBe( + "Broken.add produced data its own schema rejects: fingerprint: Too small: expected string to have >=12 characters", + ); }); test("a field transform is applied exactly once, not once per validation pass", () => { diff --git a/packages/entity/src/entity.spec.ts b/packages/entity/src/entity.spec.ts index c39021c..af20b78 100644 --- a/packages/entity/src/entity.spec.ts +++ b/packages/entity/src/entity.spec.ts @@ -1,8 +1,10 @@ +import type { SchemaIssues } from "@unthrown/standard-schema"; import { P } from "unthrown"; import { expect, test } from "vitest"; import { z } from "zod"; import { Entity } from "./index.js"; +import { keysOf } from "./issues.js"; const OrgId = z.uuid().brand("OrgId"); const Slug = z.string().min(1).brand("Slug"); @@ -52,13 +54,63 @@ test("JSON.stringify emits data only, because methods live on the prototype", () expect(JSON.parse(JSON.stringify(Organization.decode(raw).getOrThrow()))).toEqual(raw); }); +type Flat = { readonly path: readonly PropertyKey[]; readonly message: string }; +const flatten = (issues: SchemaIssues): readonly Flat[] => + issues.map((i) => ({ path: keysOf(i), message: i.message })); + +const orgIssuesOf = (r: ReturnType): readonly Flat[] => + r.match({ + ok: () => [{ path: [], message: "WRONGLY ACCEPTED" }], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => flatten(e.issues)), + defect: () => [{ path: [], message: "DEFECT" }], + }); + test("schema validation failure surfaces as InvalidEntity, not a defect", () => { - const message = Organization.decode({ ...raw, slug: "" }).match({ + const failure = Organization.decode({ ...raw, slug: "" }).match({ ok: () => "WRONGLY ACCEPTED", - errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => `${e.entity}:${e.issues.length}`), + errCases: (m) => + m.with(P.tag("InvalidEntity"), (e) => `${e.entity}:${flatten(e.issues)[0]?.path.join(".")}`), defect: () => "DEFECT", }); - expect(message).toBe("Organization:1"); + expect(failure).toBe("Organization:slug"); +}); + +test("a schema issue carries the path of the field that failed", () => { + expect(orgIssuesOf(Organization.decode({ ...raw, slug: "" }))).toEqual([ + { path: ["slug"], message: "Too small: expected string to have >=1 characters" }, + ]); +}); + +test("every failing field is reported, not just the first", () => { + expect(orgIssuesOf(Organization.decode({ ...raw, slug: "", name: "" }))).toEqual([ + { path: ["slug"], message: "Too small: expected string to have >=1 characters" }, + { path: ["name"], message: "Too small: expected string to have >=1 characters" }, + ]); +}); + +test("a whole-object issue has an empty path", () => { + expect(orgIssuesOf(Organization.decode("not an object"))).toEqual([ + { path: [], message: "Invalid input: expected object, received string" }, + ]); +}); + +test("a nested path keeps its segments, array indices included", () => { + const Tag = z.string().min(2).brand("Tag"); + const Address = z.object({ city: z.string().min(2) }).brand("Address"); + class Profile extends Entity("Profile")({ + id: OrgId, + tags: z.array(Tag).brand("Tags"), + address: Address, + }) {} + const issues = Profile.decode({ id: raw.id, tags: ["ok", "x"], address: { city: "y" } }).match({ + ok: () => [] as readonly Flat[], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => flatten(e.issues)), + defect: () => [{ path: [], message: "DEFECT" }], + }); + expect(issues).toEqual([ + { path: ["tags", 1], message: "Too small: expected string to have >=2 characters" }, + { path: ["address", "city"], message: "Too small: expected string to have >=2 characters" }, + ]); }); test("make accepts already-stored state", () => { @@ -126,11 +178,11 @@ const trialRaw = { seatsUsed: 2, }; -const issuesOf = (r: ReturnType) => +const issuesOf = (r: ReturnType): readonly Flat[] => r.match({ - ok: () => [] as readonly string[], - errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), - defect: () => ["DEFECT"], + ok: () => [] as readonly Flat[], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => flatten(e.issues)), + defect: () => [{ path: [], message: "DEFECT" }], }); test("a satisfied invariant lets the entity through", () => { @@ -139,7 +191,7 @@ test("a satisfied invariant lets the entity through", () => { test("a broken invariant surfaces as InvalidEntity", () => { expect(issuesOf(Trial.decode({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" }))).toEqual([ - "trialEndsAt must be after createdAt", + { path: [], message: "trialEndsAt must be after createdAt" }, ]); }); @@ -149,6 +201,12 @@ test("every broken rule is reported, not just the first", () => { ).toHaveLength(2); }); +test("an invariant issue has no path, unlike a schema issue", () => { + const [issue] = issuesOf(Trial.decode({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" })); + expect(issue?.path).toEqual([]); + expect(issue?.message).toBe("trialEndsAt must be after createdAt"); +}); + test("invariants also run on make", () => { expect(Trial.make({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" }).isErr()).toBe(true); }); diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index e378f65..7194871 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -6,6 +6,7 @@ import type { AddSpec } from "./add.js"; import { InvalidEntity } from "./errors.js"; import { deepFreeze } from "./freeze.js"; import { attachInstance } from "./instance.js"; +import { renderIssue } from "./issues.js"; import { shape, type OnlyNominal } from "./shape.js"; import type { AddedOf, @@ -87,8 +88,7 @@ export function Entity(tag: Tag) { // `DecodedShape` is hand-rolled alias of the same values for better error clarity const parseDecoded = fromSchema(decoded) as (d: unknown) => Result; - const toInvalidEntity = (issues: SchemaIssues) => - new InvalidEntity({ entity: tag, issues: issues.map((i) => i.message) }); + const toInvalidEntity = (issues: SchemaIssues) => new InvalidEntity({ entity: tag, issues }); /** * Validates ONLY what `add` returned, never the kept fields: `decode` @@ -118,8 +118,9 @@ export function Entity(tag: Tag) { d: DecodedShape, ): Result => { const broken = invariants?.(d) ?? []; + // no `path` — an invariant spans the entity, not one field return broken.length > 0 - ? Err(new InvalidEntity({ entity: tag, issues: broken })) + ? Err(new InvalidEntity({ entity: tag, issues: broken.map((message) => ({ message })) })) : Ok(new Ctor(d as Sealed)); }; @@ -198,7 +199,7 @@ export function Entity(tag: Tag) { defect( new Error( `${tag}.add produced data its own schema rejects: ${issues - .map((i) => i.message) + .map(renderIssue) .join("; ")}`, ), ), diff --git a/packages/entity/src/errors.ts b/packages/entity/src/errors.ts index a9e37c7..3b1e995 100644 --- a/packages/entity/src/errors.ts +++ b/packages/entity/src/errors.ts @@ -1,6 +1,12 @@ +import type { SchemaIssues } from "@unthrown/standard-schema"; import { TaggedError } from "unthrown"; +/** + * `issues` stays structured (Standard Schema, as the validator produced it) so + * a caller can key a field-level response off `path`. An `invariants` + * violation has no `path` — that absence is what tells the two kinds apart. + */ export class InvalidEntity extends TaggedError("InvalidEntity")<{ readonly entity: string; - readonly issues: readonly string[]; + readonly issues: SchemaIssues; }> {} diff --git a/packages/entity/src/instance.spec.ts b/packages/entity/src/instance.spec.ts index 891f62d..a8cca92 100644 --- a/packages/entity/src/instance.spec.ts +++ b/packages/entity/src/instance.spec.ts @@ -66,3 +66,24 @@ test("a defect during decode propagates instead of becoming a validation issue", ) {} expect(() => Buggy.instance.parse({ id: raw.id })).toThrow("boom"); }); + +test("a nested entity's field failure reports the full path, not just the member", () => { + const result = z + .object({ owner: Organization.instance }) + .safeParse({ owner: { id: raw.id, slug: "" } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["owner", "slug"]); + } +}); + +test("a nested invariant failure lands on the member itself, having no path", () => { + const result = z + .object({ owner: Organization.instance }) + .safeParse({ owner: { ...raw, slug: "reserved" } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["owner"]); + expect(result.error.issues[0]?.message).toBe("slug must not be reserved"); + } +}); diff --git a/packages/entity/src/instance.ts b/packages/entity/src/instance.ts index c479c02..b008093 100644 --- a/packages/entity/src/instance.ts +++ b/packages/entity/src/instance.ts @@ -2,6 +2,7 @@ import { P, type Result } from "unthrown"; import { z } from "zod"; import type { InvalidEntity } from "./errors.js"; +import { keysOf } from "./issues.js"; /** * The composable surface: encoded input decoded to a class instance. @@ -28,8 +29,10 @@ function instanceSchema( decodeFrom(d) .recoverErrCases((m) => m.with(P.tag("InvalidEntity"), (invalid) => { - for (const message of invalid.issues) { - ctx.addIssue({ code: "custom", message }); + for (const issue of invalid.issues) { + // zod prefixes this schema's position, so forwarding the issue's + // own path yields the full `["owner", "secret"]`. + ctx.addIssue({ code: "custom", message: issue.message, path: keysOf(issue) }); } return z.NEVER; }), diff --git a/packages/entity/src/issues.ts b/packages/entity/src/issues.ts new file mode 100644 index 0000000..118fca1 --- /dev/null +++ b/packages/entity/src/issues.ts @@ -0,0 +1,19 @@ +import type { SchemaIssues } from "@unthrown/standard-schema"; + +type IssuePath = NonNullable; + +/** + * A path segment is either a bare `PropertyKey` or a `{ key }` wrapper — + * Standard Schema permits both, and zod v4 emits the bare form. + */ +const keyOf = (segment: IssuePath[number]): PropertyKey => + typeof segment === "object" ? segment.key : segment; + +/** A Standard Schema path as plain keys, which is what zod's `addIssue` wants. */ +export const keysOf = (issue: SchemaIssues[number]): PropertyKey[] => (issue.path ?? []).map(keyOf); + +/** Human-readable text for a defect message, which has nowhere to put structure. */ +export const renderIssue = (issue: SchemaIssues[number]): string => { + const path = keysOf(issue).map(String).join("."); + return path.length === 0 ? issue.message : `${path}: ${issue.message}`; +};