From d85bf8a372a899aac02c000f70aa2ce5b3eac549 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 6 Aug 2026 19:08:34 +0200 Subject: [PATCH 1/2] feat: prefix InvalidEntity issue strings with the failing field path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema issues now render as ": " — dotted for nesting, array indices as ordinary segments ("secret: Too small: …", "tags.0: …", "address.city: …") — so a caller can tell which field failed and key a field-level error response by splitting on the first ": ". `issues` stays a readonly string[]: the path goes into the string rather than alongside it, keeping one representation of an issue instead of two parallel ones, and keeping the change non-breaking. Whole-object issues (empty path) and `invariants` messages — domain sentences about the entity, not field complaints — stay unprefixed. --- .changeset/invalid-entity-issue-paths.md | 7 +++ README.md | 31 ++++++++++--- packages/entity/README.md | 6 ++- packages/entity/src/decoded.spec.ts | 34 +++++++++++++- packages/entity/src/entity.spec.ts | 59 ++++++++++++++++++++++-- packages/entity/src/entity.ts | 38 ++++++++++++++- 6 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 .changeset/invalid-entity-issue-paths.md diff --git a/.changeset/invalid-entity-issue-paths.md b/.changeset/invalid-entity-issue-paths.md new file mode 100644 index 0000000..cc89425 --- /dev/null +++ b/.changeset/invalid-entity-issue-paths.md @@ -0,0 +1,7 @@ +--- +"@btravstack/entity": minor +--- + +`InvalidEntity.issues` now prefixes each schema issue with the failing field's +dotted path (`"secret: Too small: …"`, `"tags.0: …"`); `invariants` messages +stay unprefixed. diff --git a/README.md b/README.md index e95dc37..91f667c 100644 --- a/README.md +++ b/README.md @@ -415,16 +415,33 @@ class InvalidEntity extends TaggedError("InvalidEntity")<{ }> {} ``` -| 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`, path-prefixed | bad input, expected — the issue string names the field that failed | +| 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 | + +A **schema** issue carries the failing field's path, rendered into the string +as `": "` — dotted for nesting, with array indices as ordinary +segments (`"tags.0: …"`, `"address.city: …"`). Splitting on the first `": "` +recovers the path, so a caller can key a field-level error response by it. An +issue with no path — a whole-object failure, such as a non-object input — +stays unprefixed, and so do `invariants` messages: those are domain sentences +about the entity, not field-level complaints. ```ts ApiKey.decode({ ...raw, secret: "short" }); -// Err(InvalidEntity { entity: "ApiKey", issues: ["Too small: expected string to have >=16 characters"] }) +// Err(InvalidEntity { +// entity: "ApiKey", +// issues: ["secret: Too small: expected string to have >=16 characters"], +// }) + +Trial.decode(brokenRow); +// Err(InvalidEntity { +// entity: "Trial", +// issues: ["trialEndsAt must be after createdAt"], // an invariant: no prefix +// }) 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..a96f72d 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -35,7 +35,11 @@ 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 a `readonly string[]`; a **schema** issue +is prefixed with the failing field's dotted path, as in +`"secret: Too small: expected string to have >=16 characters"` or +`"tags.0: …"`, while an `invariants` message — a sentence about the whole +entity — is not. 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/decoded.spec.ts b/packages/entity/src/decoded.spec.ts index 35204d2..378b8a3 100644 --- a/packages/entity/src/decoded.spec.ts +++ b/packages/entity/src/decoded.spec.ts @@ -48,6 +48,13 @@ test("decode does not round-trip through encode for a split entity", () => { expect(ApiKey.make(key.encode()).isOk()).toBe(true); }); +const issuesOf = (r: ReturnType) => + r.match({ + ok: () => ["WRONGLY ACCEPTED"] as readonly string[], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), + defect: () => ["DEFECT"], + }); + test("bad caller input is InvalidEntity, never a defect", () => { const outcome = ApiKey.decode({ ...raw, secret: "short" }).match({ ok: () => "WRONGLY ACCEPTED", @@ -57,6 +64,25 @@ test("bad caller input is InvalidEntity, never a defect", () => { expect(outcome).toBe("invalid"); }); +test("a single bad field is named in the issue", () => { + expect(issuesOf(ApiKey.decode({ ...raw, secret: "short" }))).toEqual([ + "secret: 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([ + "orgId: Invalid UUID", + "secret: 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`, so the + // path is the one the caller sent + expect(issuesOf(ApiKey.decode({ ...raw, secret: "short" }))[0]).toMatch(/^secret: /); +}); + test("add producing data its own schema rejects is a defect", () => { class Broken extends Entity("Broken")( { id: ApiKeyId, secret: Secret }, @@ -73,9 +99,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..6e88732 100644 --- a/packages/entity/src/entity.spec.ts +++ b/packages/entity/src/entity.spec.ts @@ -52,13 +52,58 @@ test("JSON.stringify emits data only, because methods live on the prototype", () expect(JSON.parse(JSON.stringify(Organization.decode(raw).getOrThrow()))).toEqual(raw); }); +const orgIssuesOf = (r: ReturnType) => + r.match({ + ok: () => ["WRONGLY ACCEPTED"] as readonly string[], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), + defect: () => ["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}:${e.issues.join("|")}`), defect: () => "DEFECT", }); - expect(message).toBe("Organization:1"); + expect(failure).toBe("Organization:slug: Too small: expected string to have >=1 characters"); +}); + +test("a schema issue names the field that failed", () => { + expect(orgIssuesOf(Organization.decode({ ...raw, slug: "" }))).toEqual([ + "slug: Too small: expected string to have >=1 characters", + ]); +}); + +test("every failing field is named, not just the first", () => { + expect(orgIssuesOf(Organization.decode({ ...raw, slug: "", name: "" }))).toEqual([ + "slug: Too small: expected string to have >=1 characters", + "name: Too small: expected string to have >=1 characters", + ]); +}); + +test("a whole-object issue has no path, so it stays unprefixed", () => { + expect(orgIssuesOf(Organization.decode("not an object"))).toEqual([ + "Invalid input: expected object, received string", + ]); +}); + +test("a nested path renders dotted, with array indices as segments", () => { + 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 string[], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), + defect: () => ["DEFECT"], + }); + expect(issues).toEqual([ + "tags.1: Too small: expected string to have >=2 characters", + "address.city: Too small: expected string to have >=2 characters", + ]); }); test("make accepts already-stored state", () => { @@ -149,6 +194,14 @@ test("every broken rule is reported, not just the first", () => { ).toHaveLength(2); }); +test("an invariant message is never path-prefixed", () => { + // invariants are domain sentences about the whole entity, not field-level + // schema issues, so nothing is prepended even when they name a field + const [issue] = issuesOf(Trial.decode({ ...trialRaw, trialEndsAt: "2026-07-01T09:00:00Z" })); + expect(issue).toBe("trialEndsAt must be after createdAt"); + expect(issue).not.toContain(": "); +}); + 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..729ccd7 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -24,6 +24,40 @@ import type { const maskOf = (keys: readonly PropertyKey[]) => Object.fromEntries(keys.map((k) => [k, true as const])); +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. `typeof` + * separates them without a property probe: only the wrapper is an object. + */ +const keyOf = (segment: IssuePath[number]): PropertyKey => + typeof segment === "object" ? segment.key : segment; + +/** + * Renders a schema issue as `": "`, so a caller can tell which + * field failed: `"secret: Too small: …"`, `"tags.0: …"` for an array element, + * `"nested.deep: …"` for a nested object. An empty path — a whole-object + * issue, which is what zod reports for `path: []` on e.g. a non-object input + * — stays unprefixed rather than growing a meaningless `": "`. + * + * The path goes *into the string* instead of alongside it: `issues` stays a + * `readonly string[]`, so nothing about `InvalidEntity` breaks and there is + * still one representation of an issue rather than two parallel ones. The + * separator is `": "`, so splitting on the first occurrence recovers the + * path when a caller wants to key a field-level error response by it. + * + * Only schema issues pass through here. `invariants` messages are already + * plain domain sentences about the whole entity, and `construct` puts them on + * `InvalidEntity` untouched. + */ +const describeIssue = (issue: SchemaIssues[number]): string => { + const path = issue.path ?? []; + return path.length === 0 + ? issue.message + : `${path.map((s) => String(keyOf(s))).join(".")}: ${issue.message}`; +}; + /** * `class X extends Entity("X")({ …fields }) {}` * @@ -88,7 +122,7 @@ export function Entity(tag: Tag) { const parseDecoded = fromSchema(decoded) as (d: unknown) => Result; const toInvalidEntity = (issues: SchemaIssues) => - new InvalidEntity({ entity: tag, issues: issues.map((i) => i.message) }); + new InvalidEntity({ entity: tag, issues: issues.map(describeIssue) }); /** * Validates ONLY what `add` returned, never the kept fields: `decode` @@ -198,7 +232,7 @@ export function Entity(tag: Tag) { defect( new Error( `${tag}.add produced data its own schema rejects: ${issues - .map((i) => i.message) + .map(describeIssue) .join("; ")}`, ), ), From 73c24e4bcb689fb7eab99e8e9c1159e9c0303cbd Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 6 Aug 2026 22:27:26 +0200 Subject: [PATCH 2/2] feat!: carry Standard Schema issues on InvalidEntity --- .changeset/invalid-entity-issue-paths.md | 7 --- .../invalid-entity-structured-issues.md | 15 +++++ README.md | 35 ++++++----- packages/entity/README.md | 8 +-- packages/entity/src/crud.spec.ts | 2 +- packages/entity/src/decoded.spec.ts | 27 ++++---- packages/entity/src/entity.spec.ts | 63 ++++++++++--------- packages/entity/src/entity.ts | 43 ++----------- packages/entity/src/errors.ts | 8 ++- packages/entity/src/instance.spec.ts | 21 +++++++ packages/entity/src/instance.ts | 7 ++- packages/entity/src/issues.ts | 19 ++++++ 12 files changed, 144 insertions(+), 111 deletions(-) delete mode 100644 .changeset/invalid-entity-issue-paths.md create mode 100644 .changeset/invalid-entity-structured-issues.md create mode 100644 packages/entity/src/issues.ts diff --git a/.changeset/invalid-entity-issue-paths.md b/.changeset/invalid-entity-issue-paths.md deleted file mode 100644 index cc89425..0000000 --- a/.changeset/invalid-entity-issue-paths.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@btravstack/entity": minor ---- - -`InvalidEntity.issues` now prefixes each schema issue with the failing field's -dotted path (`"secret: Too small: …"`, `"tags.0: …"`); `invariants` messages -stay unprefixed. 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 91f667c..bf7cb43 100644 --- a/README.md +++ b/README.md @@ -411,38 +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`, path-prefixed | bad input, expected — the issue string names the field that failed | -| 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 | - -A **schema** issue carries the failing field's path, rendered into the string -as `": "` — dotted for nesting, with array indices as ordinary -segments (`"tags.0: …"`, `"address.city: …"`). Splitting on the first `": "` -recovers the path, so a caller can key a field-level error response by it. An -issue with no path — a whole-object failure, such as a non-object input — -stays unprefixed, and so do `invariants` messages: those are domain sentences -about the entity, not field-level complaints. +| 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: ["secret: Too small: expected string to have >=16 characters"], +// issues: [{ path: ["secret"], message: "Too small: expected string to have >=16 characters" }], // }) Trial.decode(brokenRow); // Err(InvalidEntity { // entity: "Trial", -// issues: ["trialEndsAt must be after createdAt"], // an invariant: no prefix +// 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 a96f72d..5f4837f 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -35,11 +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. `InvalidEntity.issues` is a `readonly string[]`; a **schema** issue -is prefixed with the failing field's dotted path, as in -`"secret: Too small: expected string to have >=16 characters"` or -`"tags.0: …"`, while an `invariants` message — a sentence about the whole -entity — is not. 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 378b8a3..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,11 +49,16 @@ test("decode does not round-trip through encode for a split entity", () => { expect(ApiKey.make(key.encode()).isOk()).toBe(true); }); -const issuesOf = (r: ReturnType) => +type Flat = { readonly path: readonly PropertyKey[]; readonly message: string }; + +const issuesOf = (r: ReturnType): readonly Flat[] => r.match({ - ok: () => ["WRONGLY ACCEPTED"] as readonly string[], - errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), - defect: () => ["DEFECT"], + 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", () => { @@ -64,23 +70,22 @@ test("bad caller input is InvalidEntity, never a defect", () => { expect(outcome).toBe("invalid"); }); -test("a single bad field is named in the issue", () => { +test("a single bad field is named by its path", () => { expect(issuesOf(ApiKey.decode({ ...raw, secret: "short" }))).toEqual([ - "secret: Too small: expected string to have >=16 characters", + { 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([ - "orgId: Invalid UUID", - "secret: Too small: expected string to have >=16 characters", + { 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`, so the - // path is the one the caller sent - expect(issuesOf(ApiKey.decode({ ...raw, secret: "short" }))[0]).toMatch(/^secret: /); + // `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", () => { diff --git a/packages/entity/src/entity.spec.ts b/packages/entity/src/entity.spec.ts index 6e88732..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,42 +54,47 @@ test("JSON.stringify emits data only, because methods live on the prototype", () expect(JSON.parse(JSON.stringify(Organization.decode(raw).getOrThrow()))).toEqual(raw); }); -const orgIssuesOf = (r: ReturnType) => +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: () => ["WRONGLY ACCEPTED"] as readonly string[], - errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues), - defect: () => ["DEFECT"], + 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 failure = Organization.decode({ ...raw, slug: "" }).match({ ok: () => "WRONGLY ACCEPTED", - errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => `${e.entity}:${e.issues.join("|")}`), + errCases: (m) => + m.with(P.tag("InvalidEntity"), (e) => `${e.entity}:${flatten(e.issues)[0]?.path.join(".")}`), defect: () => "DEFECT", }); - expect(failure).toBe("Organization:slug: Too small: expected string to have >=1 characters"); + expect(failure).toBe("Organization:slug"); }); -test("a schema issue names the field that failed", () => { +test("a schema issue carries the path of the field that failed", () => { expect(orgIssuesOf(Organization.decode({ ...raw, slug: "" }))).toEqual([ - "slug: Too small: expected string to have >=1 characters", + { path: ["slug"], message: "Too small: expected string to have >=1 characters" }, ]); }); -test("every failing field is named, not just the first", () => { +test("every failing field is reported, not just the first", () => { expect(orgIssuesOf(Organization.decode({ ...raw, slug: "", name: "" }))).toEqual([ - "slug: Too small: expected string to have >=1 characters", - "name: Too small: expected string to have >=1 characters", + { 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 no path, so it stays unprefixed", () => { +test("a whole-object issue has an empty path", () => { expect(orgIssuesOf(Organization.decode("not an object"))).toEqual([ - "Invalid input: expected object, received string", + { path: [], message: "Invalid input: expected object, received string" }, ]); }); -test("a nested path renders dotted, with array indices as segments", () => { +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")({ @@ -96,13 +103,13 @@ test("a nested path renders dotted, with array indices as segments", () => { address: Address, }) {} const issues = Profile.decode({ id: raw.id, tags: ["ok", "x"], address: { city: "y" } }).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" }], }); expect(issues).toEqual([ - "tags.1: Too small: expected string to have >=2 characters", - "address.city: Too small: expected string to have >=2 characters", + { path: ["tags", 1], message: "Too small: expected string to have >=2 characters" }, + { path: ["address", "city"], message: "Too small: expected string to have >=2 characters" }, ]); }); @@ -171,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", () => { @@ -184,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" }, ]); }); @@ -194,12 +201,10 @@ test("every broken rule is reported, not just the first", () => { ).toHaveLength(2); }); -test("an invariant message is never path-prefixed", () => { - // invariants are domain sentences about the whole entity, not field-level - // schema issues, so nothing is prepended even when they name a field +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).toBe("trialEndsAt must be after createdAt"); - expect(issue).not.toContain(": "); + expect(issue?.path).toEqual([]); + expect(issue?.message).toBe("trialEndsAt must be after createdAt"); }); test("invariants also run on make", () => { diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 729ccd7..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, @@ -24,40 +25,6 @@ import type { const maskOf = (keys: readonly PropertyKey[]) => Object.fromEntries(keys.map((k) => [k, true as const])); -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. `typeof` - * separates them without a property probe: only the wrapper is an object. - */ -const keyOf = (segment: IssuePath[number]): PropertyKey => - typeof segment === "object" ? segment.key : segment; - -/** - * Renders a schema issue as `": "`, so a caller can tell which - * field failed: `"secret: Too small: …"`, `"tags.0: …"` for an array element, - * `"nested.deep: …"` for a nested object. An empty path — a whole-object - * issue, which is what zod reports for `path: []` on e.g. a non-object input - * — stays unprefixed rather than growing a meaningless `": "`. - * - * The path goes *into the string* instead of alongside it: `issues` stays a - * `readonly string[]`, so nothing about `InvalidEntity` breaks and there is - * still one representation of an issue rather than two parallel ones. The - * separator is `": "`, so splitting on the first occurrence recovers the - * path when a caller wants to key a field-level error response by it. - * - * Only schema issues pass through here. `invariants` messages are already - * plain domain sentences about the whole entity, and `construct` puts them on - * `InvalidEntity` untouched. - */ -const describeIssue = (issue: SchemaIssues[number]): string => { - const path = issue.path ?? []; - return path.length === 0 - ? issue.message - : `${path.map((s) => String(keyOf(s))).join(".")}: ${issue.message}`; -}; - /** * `class X extends Entity("X")({ …fields }) {}` * @@ -121,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(describeIssue) }); + const toInvalidEntity = (issues: SchemaIssues) => new InvalidEntity({ entity: tag, issues }); /** * Validates ONLY what `add` returned, never the kept fields: `decode` @@ -152,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)); }; @@ -232,7 +199,7 @@ export function Entity(tag: Tag) { defect( new Error( `${tag}.add produced data its own schema rejects: ${issues - .map(describeIssue) + .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}`; +};