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
25 changes: 25 additions & 0 deletions .changeset/entity-union.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 24 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/entity/src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, () => unknown>): Record<string, unknown> =>
Expand Down Expand Up @@ -356,6 +357,13 @@ export function Entity<Tag extends string>(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 extends { readonly __input: unknown }> = E["__input"];

Expand Down
1 change: 1 addition & 0 deletions packages/entity/src/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
88 changes: 62 additions & 26 deletions packages/entity/src/union.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
39 changes: 39 additions & 0 deletions packages/entity/src/union.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Email> | 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]);
});
118 changes: 118 additions & 0 deletions packages/entity/src/union.ts
Original file line number Diff line number Diff line change
@@ -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<z.core.$ZodLooseShape>;
readonly output: z.ZodObject<z.core.$ZodLooseShape>;
readonly instance: z.ZodType;
make(state: unknown): Result<unknown, InvalidEntity>;
};
Comment thread
Copilot marked this conversation as resolved.

/**
* 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<M extends UnionMember> = z.infer<M["instance"]>;

export type EntityUnion<K extends string, M extends readonly UnionMember[]> = {
readonly discriminant: K;
readonly members: M;
readonly input: z.ZodType<unknown>;
readonly output: z.ZodType<unknown>;
readonly instance: z.ZodType<InstanceOf<M[number]>>;
make(state: unknown): Result<InstanceOf<M[number]>, InvalidEntity>;
};

/**
* `z.discriminatedUnion` constrains its branches to `$ZodTypeDiscriminable<K>`,
* 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<K, M> {
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<unknown, UnionMember>(
members.map((m) => [(m.input.shape[discriminant] as z.ZodLiteral<string>).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<string, unknown> | null | undefined)?.[discriminant]);

const unknownDiscriminant = (state: unknown) => ({
path: [discriminant] as readonly PropertyKey[],
message: `Invalid discriminant ${JSON.stringify(
(state as Record<string, unknown> | null | undefined)?.[discriminant],
)}; expected one of ${known}`,
});

const make = (state: unknown): Result<InstanceOf<M[number]>, InvalidEntity> => {
const member = lookup(state);
return member === undefined
? Err(new InvalidEntity({ entity, issues: [unknownDiscriminant(state)] }))
: (member.make(state) as Result<InstanceOf<M[number]>, 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<M[number]>;
}) as unknown as z.ZodType<InstanceOf<M[number]>>;

return { discriminant, members, input, output, instance, make };
}
Loading