Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/invalid-entity-structured-issues.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 27 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,21 +411,39 @@ Every fallible entry point returns `Result<T, InvalidEntity>`:
```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" }]
```
Expand Down
4 changes: 3 additions & 1 deletion packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ org.equals(other); // equal encoded data
Every fallible entry point (`decode`, `make`, `create`, `update`) returns an
`unthrown` `Result<T, InvalidEntity>` — 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.

Expand Down
2 changes: 1 addition & 1 deletion packages/entity/src/crud.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand Down
39 changes: 37 additions & 2 deletions packages/entity/src/decoded.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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<typeof ApiKey.decode>): 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",
Expand All @@ -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 },
Expand All @@ -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", () => {
Expand Down
74 changes: 66 additions & 8 deletions packages/entity/src/entity.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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<typeof Organization.decode>): 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", () => {
Expand Down Expand Up @@ -126,11 +178,11 @@ const trialRaw = {
seatsUsed: 2,
};

const issuesOf = (r: ReturnType<typeof Trial.decode>) =>
const issuesOf = (r: ReturnType<typeof Trial.decode>): 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", () => {
Expand All @@ -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" },
]);
});

Expand All @@ -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);
});
Expand Down
9 changes: 5 additions & 4 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,8 +88,7 @@ export function Entity<Tag extends string>(tag: Tag) {
// `DecodedShape` is hand-rolled alias of the same values for better error clarity
const parseDecoded = fromSchema(decoded) as (d: unknown) => Result<DecodedShape, SchemaIssues>;

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`
Expand Down Expand Up @@ -118,8 +118,9 @@ export function Entity<Tag extends string>(tag: Tag) {
d: DecodedShape,
): Result<T, InvalidEntity> => {
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<DecodedShape>));
};

Expand Down Expand Up @@ -198,7 +199,7 @@ export function Entity<Tag extends string>(tag: Tag) {
defect(
new Error(
`${tag}.add produced data its own schema rejects: ${issues
.map((i) => i.message)
.map(renderIssue)
.join("; ")}`,
),
),
Expand Down
8 changes: 7 additions & 1 deletion packages/entity/src/errors.ts
Original file line number Diff line number Diff line change
@@ -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;
}> {}
21 changes: 21 additions & 0 deletions packages/entity/src/instance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
});
7 changes: 5 additions & 2 deletions packages/entity/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -28,8 +29,10 @@ function instanceSchema<T>(
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;
}),
Expand Down
19 changes: 19 additions & 0 deletions packages/entity/src/issues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { SchemaIssues } from "@unthrown/standard-schema";

type IssuePath = NonNullable<SchemaIssues[number]["path"]>;

/**
* 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}`;
};