diff --git a/.changeset/entity-union.md b/.changeset/entity-union.md new file mode 100644 index 0000000..82331fe --- /dev/null +++ b/.changeset/entity-union.md @@ -0,0 +1,25 @@ +--- +"@btravstack/entity": minor +--- + +New `Entity.union(discriminant, members)`: a union of entities that is itself +entity-like. Grouped under `Entity` rather than exported loose — `union` alone +is too generic a name to take from a consumer's import scope. + +```ts +const Member = Entity.union("kind", [User, ServiceAccount]); + +Member.make(row).getOrThrow(); // User | ServiceAccount +Member.input; // discriminated union, one branch per member +Member.output; // ditto — JSON Schema in both directions +Member.instance; // parses to the member class, and nests as a field +Member.members; // the tuple, for registries and exhaustiveness +``` + +Previously a union of entities was a plain zod schema and you had to choose +which half to lose: `z.discriminatedUnion` over the `output` schemas gave a +contract but plain data, while `z.union` over the `instance` schemas gave +instances but no output JSON Schema. Neither had `make`. + +It dispatches on the discriminant rather than trying each branch, so a member +whose own validation fails reports its own issues rather than every branch's. diff --git a/README.md b/README.md index 56e8635..cc3e9a2 100644 --- a/README.md +++ b/README.md @@ -444,22 +444,36 @@ class ServiceAccount extends Entity("ServiceAccount")({ label: Label, }) {} -const Member = z.discriminatedUnion("kind", [ - User.output, - ServiceAccount.output, -]); +const Member = Entity.union("kind", [User, ServiceAccount]); ``` -This parses both members, rejects an unknown discriminant, and generates JSON -Schema in both directions with one branch per member — because `kind` is a -real field on a real `ZodObject`, not framework metadata layered on top. A -union of the `instance` surfaces parses to the right class: +`union` gives you one artifact with both halves, rather than making you choose: ```ts -const Instances = z.union([User.instance, ServiceAccount.instance]); -Instances.parse(userRow) instanceof User; // true +Member.make(row).getOrThrow(); // User | ServiceAccount — the real class +Member.input; // discriminated union, one branch per member +Member.output; // ditto — JSON Schema in both directions +Member.instance; // parses to the member class; nests like any other +Member.members; // the tuple, for registries and exhaustiveness ``` +The union **dispatches on the discriminant** rather than trying each branch in +turn, so a member whose own validation fails reports _its_ issues — `path: +["email"]` — instead of a pile of every branch's complaints. An unrecognised +discriminant names the key and lists what was expected. + +A union is a valid field too, so an aggregate can hold one: + +```ts +class Audit extends Entity("Audit")({ id: AuditId, actor: Member.instance }) {} +``` + +**Why the discriminant is a declared field and not the tag.** `_tag` is +non-enumerable and absent after serialisation, so a union built on it could not +survive a JSON round trip. The two are not redundant: `kind` discriminates +_data_, `_tag` matches an _instance_ with `P.tag(...)`. `union` needs the +first; the second keeps working on whatever it returns. + and `_tag` still serves in domain code, exactly as in the `match` example above — the two mechanisms solve different problems. A brand is _per field_ and _type-only_ (it disappears at runtime); the tag is _per entity_ and diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 6e13b66..2886482 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -22,6 +22,7 @@ import type { Sealed, UpdateInputShapeOf, } from "./types.js"; +import { union } from "./union.js"; /** Calls every generator once, in declaration order. */ const callAll = (generators: Record unknown>): Record => @@ -356,6 +357,13 @@ export function Entity(tag: Tag) { }; } +/** + * Grouped under `Entity` rather than exported loose: `union` alone is too + * generic a name to take from a consumer's import scope, and it reads as + * `z.union`'s sibling when it is nothing of the sort. + */ +Entity.union = union; + /** What the wire sends — for mapper and request signatures. */ export type Input = E["__input"]; diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index b83ee7a..a1f0b34 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -1,5 +1,6 @@ export { Entity, type CreateInput, type Input, type Output, type Patch } from "./entity.js"; export { computed, type ComputedField } from "./computed.js"; +export type { EntityUnion } from "./union.js"; export { InvalidEntity } from "./errors.js"; // Exported only so a consumer's emitted declarations can name them — none of // the three is part of the API you write against. See `Sealed` in types.ts. diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 391a16b..6439d2a 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -21,48 +21,84 @@ class ServiceAccount extends Entity("ServiceAccount")({ label: Label, }) {} -const Member = z.discriminatedUnion("kind", [User.output, ServiceAccount.output]); +const Member = Entity.union("kind", [User, ServiceAccount]); -const userRow = { - kind: "user", - id: "0199b1f4-1b1e-7000-8000-000000000000", - email: "a@b.com", -}; +const userRow = { kind: "user", id: "0199b1f4-1b1e-7000-8000-000000000000", email: "a@b.com" }; const svcRow = { kind: "service_account", id: "0199b1f4-1b1e-7000-8000-000000000001", label: "deploy-bot", }; -test("the discriminated union parses both members", () => { - expect(Member.parse(userRow).kind).toBe("user"); - expect(Member.parse(svcRow).kind).toBe("service_account"); +test("make yields the right class for each member", () => { + expect(Member.make(userRow).getOrThrow()).toBeInstanceOf(User); + expect(Member.make(svcRow).getOrThrow()).toBeInstanceOf(ServiceAccount); +}); + +test("the resulting instance keeps its behaviour and tag", () => { + const m = Member.make(userRow).getOrThrow(); + const described = match(m) + .with(P.tag("User"), (u) => `user:${u.email}`) + .with(P.tag("ServiceAccount"), (s) => `svc:${s.label}`) + .exhaustive(); + expect(described).toBe("user:a@b.com"); +}); + +test("an unknown discriminant fails with the key and the options", () => { + const issues = Member.make({ ...userRow, kind: "nope" }).match({ + ok: () => [] as readonly string[], + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => i.message)), + defect: () => ["DEFECT"], + }); + expect(issues[0]).toContain('Invalid discriminant "nope"'); + expect(issues[0]).toContain('"user"'); + expect(issues[0]).toContain('"service_account"'); }); -test("the union rejects an unknown discriminant", () => { - expect(Member.safeParse({ ...userRow, kind: "nope" }).success).toBe(false); +test("a member's own validation failure reports only that member's issues", () => { + // dispatching on the discriminant is what keeps this from reporting every + // branch's complaints, which is what a plain z.union would do + const issues = Member.make({ ...userRow, email: "not-an-email" }).match({ + ok: () => [] as readonly string[], + errCases: (m) => + m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => String(i.path?.[0]))), + defect: () => ["DEFECT"], + }); + expect(issues).toEqual(["email"]); }); -test("the union generates JSON Schema in BOTH directions, one branch per member", () => { +test("input and output generate JSON Schema in both directions, one branch per member", () => { for (const io of ["input", "output"] as const) { - const js = z.toJSONSchema(Member, { io }) as { anyOf?: unknown[]; oneOf?: unknown[] }; - expect((js.anyOf ?? js.oneOf ?? []).length).toBe(2); + for (const schema of [Member.input, Member.output]) { + const js = z.toJSONSchema(schema, { io }) as { anyOf?: unknown[]; oneOf?: unknown[] }; + expect((js.anyOf ?? js.oneOf ?? []).length).toBe(2); + } } }); -test("a union of instance surfaces yields the right class", () => { - const Instances = z.union([User.instance, ServiceAccount.instance]); - expect(Instances.parse(userRow)).toBeInstanceOf(User); - expect(Instances.parse(svcRow)).toBeInstanceOf(ServiceAccount); +test("instance parses to the member class and nests", () => { + expect(Member.instance.parse(userRow)).toBeInstanceOf(User); + const Wrapper = z.object({ member: Member.instance }); + expect(Wrapper.parse({ member: svcRow }).member).toBeInstanceOf(ServiceAccount); }); -const describe = (m: User | ServiceAccount) => - match(m) - .with(P.tag("User"), (u) => `user:${u.email}`) - .with(P.tag("ServiceAccount"), (s) => `svc:${s.label}`) - .exhaustive(); +test("a nested member failure keeps the outer path", () => { + const result = z + .object({ member: Member.instance }) + .safeParse({ member: { ...userRow, email: "nope" } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.path).toEqual(["member", "email"]); + } +}); + +test("the members are reachable, for exhaustiveness and registries", () => { + expect(Member.discriminant).toBe("kind"); + expect(Member.members.map((m) => m.entityName)).toEqual(["User", "ServiceAccount"]); +}); -test("entities match with P.tag on the runtime tag", () => { - expect(describe(User.make(userRow).getOrThrow())).toBe("user:a@b.com"); - expect(describe(ServiceAccount.make(svcRow).getOrThrow())).toBe("svc:deploy-bot"); +test("a union member can itself be a field of another entity", () => { + class Audit extends Entity("Audit")({ id: UserId, actor: Member.instance }) {} + const a = Audit.make({ id: userRow.id, actor: svcRow }).getOrThrow(); + expect(a.actor).toBeInstanceOf(ServiceAccount); }); diff --git a/packages/entity/src/union.test-d.ts b/packages/entity/src/union.test-d.ts new file mode 100644 index 0000000..0789058 --- /dev/null +++ b/packages/entity/src/union.test-d.ts @@ -0,0 +1,39 @@ +import { match, P } from "unthrown"; +import { test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const UserId = z.uuid().brand("UserId"); +const Email = z.email().brand("Email"); +const Label = z.string().min(1).brand("Label"); + +class User extends Entity("User")({ kind: z.literal("user"), id: UserId, email: Email }) {} +class Svc extends Entity("Svc")({ kind: z.literal("svc"), id: UserId, label: Label }) {} + +const Member = Entity.union("kind", [User, Svc]); + +test("make yields the member union, not unknown", () => { + const m = Member.make({}).getOrThrow(); + // reachable only if the union type is precise + const email: z.infer | undefined = + "kind" in m && m.kind === "user" ? m.email : undefined; + void email; + // @ts-expect-error `label` is not on the `user` branch + const wrong = m.kind === "user" ? m.label : undefined; + void wrong; +}); + +test("the union is exhaustively matchable on the runtime tag", () => { + const m = Member.make({}).getOrThrow(); + const described: string = match(m) + .with(P.tag("User"), (u) => u.email as string) + .with(P.tag("Svc"), (s) => s.label as string) + .exhaustive(); + void described; +}); + +test("a union needs at least two members", () => { + // @ts-expect-error one member is not a union + Entity.union("kind", [User]); +}); diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts new file mode 100644 index 0000000..106f4f4 --- /dev/null +++ b/packages/entity/src/union.ts @@ -0,0 +1,118 @@ +import { Err, type Result } from "unthrown"; +import { z } from "zod"; + +import { InvalidEntity } from "./errors.js"; + +/** + * The part of an entity a union needs. Typed loosely — `EntityStatic` is + * generic in the entity's own shape, and a union has to accept any of them. + */ +type UnionMember = { + readonly entityName: string; + readonly input: z.ZodObject; + readonly output: z.ZodObject; + readonly instance: z.ZodType; + make(state: unknown): Result; +}; + +/** + * The member's instance type, read off `instance` rather than off `make`. + * `make` is generic in a `this` parameter, which cannot be inferred through a + * loosened member type; `instance` states the same type plainly. + */ +type InstanceOf = z.infer; + +export type EntityUnion = { + readonly discriminant: K; + readonly members: M; + readonly input: z.ZodType; + readonly output: z.ZodType; + readonly instance: z.ZodType>; + make(state: unknown): Result, InvalidEntity>; +}; + +/** + * `z.discriminatedUnion` constrains its branches to `$ZodTypeDiscriminable`, + * which asserts the branch's *input* carries the key. A member's schema is + * generic here, so TypeScript cannot see that it does — the runtime check in + * `byValue` below reads the very literal that proves it. Cast rather than + * fight a constraint the construction already satisfies. + */ +type Branches = readonly [z.core.$ZodTypeDiscriminable, ...z.core.$ZodTypeDiscriminable[]]; + +/** + * A union of entities that is itself usable like one: it validates, it makes + * the right class, and it hands a contract layer plain schemas. + * + * ```ts + * const Member = union("kind", [User, ServiceAccount]); + * Member.make(row).getOrThrow(); // User | ServiceAccount + * ``` + * + * `discriminant` names a **declared domain field**, not the entity's `_tag`. + * The tag is non-enumerable and absent after serialisation, so a union built + * on it could not survive a JSON round trip. The two mechanisms are not + * redundant: the field discriminates *data*, the tag matches an *instance* + * with `P.tag(...)`. + * + * `input` and `output` are real discriminated unions, so a contract layer gets + * one branch per member and JSON Schema in both directions. + */ +export function union< + const K extends string, + const M extends readonly [UnionMember, UnionMember, ...UnionMember[]], +>(discriminant: K, members: M): EntityUnion { + const input = z.discriminatedUnion( + discriminant, + members.map((m) => m.input) as unknown as Branches, + ); + const output = z.discriminatedUnion( + discriminant, + members.map((m) => m.output) as unknown as Branches, + ); + + const byValue = new Map( + members.map((m) => [(m.input.shape[discriminant] as z.ZodLiteral).value, m]), + ); + + const entity = members.map((m) => m.entityName).join(" | "); + const known = [...byValue.keys()].map((k) => JSON.stringify(k)).join(", "); + + /** Dispatch on the discriminant rather than trying each branch in turn, so a + * failing member reports *its* issues instead of every branch's. */ + const lookup = (state: unknown): UnionMember | undefined => + byValue.get((state as Record | null | undefined)?.[discriminant]); + + const unknownDiscriminant = (state: unknown) => ({ + path: [discriminant] as readonly PropertyKey[], + message: `Invalid discriminant ${JSON.stringify( + (state as Record | null | undefined)?.[discriminant], + )}; expected one of ${known}`, + }); + + const make = (state: unknown): Result, InvalidEntity> => { + const member = lookup(state); + return member === undefined + ? Err(new InvalidEntity({ entity, issues: [unknownDiscriminant(state)] })) + : (member.make(state) as Result, InvalidEntity>); + }; + + const instance = z.unknown().transform((raw, ctx) => { + const member = lookup(raw); + if (member === undefined) { + const issue = unknownDiscriminant(raw); + ctx.addIssue({ code: "custom", message: issue.message, path: [discriminant] }); + return z.NEVER; + } + const parsed = member.instance.safeParse(raw); + if (!parsed.success) { + for (const issue of parsed.error.issues) { + ctx.addIssue({ code: "custom", message: issue.message, path: [...issue.path] }); + } + return z.NEVER; + } + return parsed.data as InstanceOf; + }) as unknown as z.ZodType>; + + return { discriminant, members, input, output, instance, make }; +}