From 60e10a494299d370e2c22ca9a6bb6da5ded247f2 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 23:36:08 +0200 Subject: [PATCH 1/7] feat!: a union is a value, not a class --- docs/typedoc.json | 1 - packages/entity/src/types.ts | 2 - packages/entity/src/union.spec.ts | 21 ++----- packages/entity/src/union.test-d.ts | 44 +++++--------- packages/entity/src/union.ts | 93 ++++++----------------------- 5 files changed, 39 insertions(+), 122 deletions(-) diff --git a/docs/typedoc.json b/docs/typedoc.json index 156e67e..bbf759c 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -43,7 +43,6 @@ "Schemas", "SchemasOf", "SealedSrc", - "SharedBase", "UnknownFlagIsRejected", "UpdateInputShapeOf" ] diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index e79ed68..85b1819 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -447,8 +447,6 @@ export type EntityStatic< readonly __output: OutputOf; readonly __createInput: CreateInputOf>; readonly __patch: PatchOf>; - /** the *instance type* of the abstract root this was extended from, read by `Entity.union` */ - readonly __base: B; /** the instance type, read by `Entity.Instance` */ readonly __instance: ConstructedInstance & B; make(this: new (d: Sealed>) => T, state: unknown): Result; diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 45f2749..3533cab 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -172,27 +172,16 @@ class Business extends AccountBase.extend("Business")({ kind: z.literal("busines } } -class Account extends Entity.union("kind", [Personal, Business]) { - static ofLabel(label: string) { - return Account.make({ id: "0199b1f4-1b1e-7000-8000-000000000002", label, kind: "personal" }); - } -} +const Account = Entity.union("kind", [Personal, Business]); -test("a union declared as a class still dispatches to the member", () => { - expect(Account.ofLabel("Ada").getOrThrow()).toBeInstanceOf(Personal); +test("a union dispatches to the member from a row", () => { + const row = { id: "0199b1f4-1b1e-7000-8000-000000000002", label: "Ada", kind: "personal" }; + expect(Account.make(row).getOrThrow()).toBeInstanceOf(Personal); expect(Account.discriminant).toBe("kind"); expect(Account.members.map((m) => m.entityName)).toEqual(["Personal", "Business"]); }); -test("a union declared as a class is still a schema", () => { +test("a union is a schema, so it nests", () => { const row = { id: "0199b1f4-1b1e-7000-8000-000000000003", label: "Acme", kind: "business" }; expect(z.array(Account).parse([row])[0]).toBeInstanceOf(Business); }); - -test("a union has no instances", () => { - const Ctor = Account as unknown as new () => unknown; - // A union's `make` dispatches to a member class, so nothing is ever an - // instance of the union itself — an instance method written in a union's - // class body would never reach a member, and this is what says so. - expect(() => new Ctor()).toThrow(/no instances/); -}); diff --git a/packages/entity/src/union.test-d.ts b/packages/entity/src/union.test-d.ts index d58d4b6..09a4772 100644 --- a/packages/entity/src/union.test-d.ts +++ b/packages/entity/src/union.test-d.ts @@ -57,36 +57,24 @@ class Business extends AccountBase.extend("Business")({ kind: z.literal("busines } } -class Account extends Entity.union("kind", [Personal, Business]) {} -class Mixed extends Entity.union("kind", [User, Personal]) {} +const Payment = Entity.union("kind", [Personal, Business]); +type Payment = Entity.Instance; -// `declare` is illegal inside a function body, so both annotations live here. -declare const anyAccount: Account; -declare const anyMixed: Mixed; +declare const p: Payment; -test("a union class is usable as a type — the members' shared root", () => { - const described: string = anyAccount.describe(); - const slug: string = anyAccount.slug; - void described; - void slug; - // @ts-expect-error the supertype is the shared root, not either variant - void anyAccount.kind; -}); - -test("Entity.Instance recovers the exact member union", () => { - const x = Account.make({}).getOrThrow(); - const y: Entity.Instance = x; - const described: string = match(y) - .with(P.tag("Personal"), (p) => p.describe()) - .with(P.tag("Business"), (b) => b.describe()) - .exhaustive(); - void described; +test("a union has no class form", () => { + // The class form typed as the members' shared root and could not narrow + // (#57). A class's instance type cannot be a union at all — `TS2509` — + // so the value plus `Entity.Instance` is the only honest spelling. + // @ts-expect-error a union is a value, not a constructor + class Nope extends Entity.union("kind", [Personal, Business]) {} + void Nope; }); -test("members from different roots claim no shared supertype", () => { - // `User` is declared straight from `Entity(...)`, so its `__base` is the - // empty type; `Personal`'s is `AccountBase`. Two different types is a union, - // which `SoleType` refuses to claim. - // @ts-expect-error nothing is shared, so nothing is claimed - void anyMixed.describe(); +test("the const plus Entity.Instance pair narrows to a member", () => { + const onTag: string = p._tag === "Personal" ? p.describe() : p.describe(); + void onTag; + // narrowing on the declared discriminant reaches member-only fields + const onDiscriminant: string = p.kind === "personal" ? p._tag : p._tag; + void onDiscriminant; }); diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index 09a1bb9..ad4e8df 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -12,8 +12,6 @@ export type UnionMember = { readonly entityName: string; readonly input: z.ZodObject; readonly output: z.ZodObject; - /** the *instance type* of the abstract root the member was extended from, or the empty type */ - readonly __base: unknown; make(state: unknown): Result; } & z.core.$ZodType; @@ -25,54 +23,18 @@ export type UnionMember = { */ type InstanceOf = z.infer; -type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends ( - x: infer I, -) => void - ? I - : never; - /** - * `T` when it is a single object type, and the empty type otherwise. + * What `Entity.union(...)` returns: a value, never a constructor. * - * `UnionToIntersection` is `A & B`, which `A | B` does not extend, so - * the test distinguishes one type from several. That is what makes the union's - * construct signature legal: a base-constructor return type may not be a union - * (`TS2509: Base constructor return type 'Personal | Business' is not an object - * type or intersection of object types with statically known members`), so - * members drawn from different roots — or from none — fall back to claiming - * nothing rather than claiming a supertype they do not share. + * There is deliberately no `new` signature. A class's instance type cannot be + * a union — `TS2509: Base constructor return type 'Personal | Business' is not + * an object type or intersection of object types with statically known + * members` — so a class form could only ever type as the members' shared root, + * which is both unable to narrow and redundant with the root the author + * already named. It shipped in 0.4.0 and was removed in #57. `TS2507` at the + * declaration is the replacement, and it fires where the mistake is written. */ -type SoleType = [T] extends [UnionToIntersection] - ? [T] extends [object] - ? T - : Record - : Record; - -/** - * The same members, carried as an anonymous object type. - * - * Not a no-op: `__base` is the root's own instance type, abstract declarations - * and all, so a plain `class Account extends Entity.union(...) {}` was measured - * to fail with `TS2515: Non-abstract class 'Account' does not implement - * inherited abstract member describe from class 'AccountBase'`. A union's class - * body holds statics only — it can never implement an instance member, and - * nothing is ever constructed from it — so the abstractness is noise here. - * Mapping is safe where `BehaviourOf` could not do it: no variant overrides - * anything through this type, which is what TS2425 needs. - */ -type Plain = { [K in keyof T]: T[K] }; - -/** The root every member shares, or the empty type if they do not share one. */ -type SharedBase = Plain>; - export type EntityUnion = { - /** - * Sealed, and never actually constructed — a union has no instances. It - * exists so `class Account extends Entity.union(...) {}` compiles and - * `Account` is usable as a type. That type is the members' shared root, not - * the member union: see `SoleType`. - */ - new (d: never): SharedBase; readonly discriminant: K; readonly members: M; readonly input: z.ZodType; @@ -234,35 +196,16 @@ export function union< .get() as InstanceOf; }) as unknown as z.ZodType>; - class EntityUnionBase { - static readonly discriminant = discriminant; - static readonly members = members; - static readonly input = input; - static readonly output = output; - static readonly make = make; - - constructor() { - // A defect, not an `InvalidEntity`: `make` dispatches to a member class, - // so nothing is ever an instance of the union. Reaching this means an - // instance method was written in a union's class body, where it could - // never have reached a member. - // oxlint-disable-next-line unthrown/no-throw - throw new Error(`${entity}: a union has no instances — use make()`); - } - } - // the same two slots an entity carries, so a union composes identically — - // `z.object({ member: Member })`, or as a field of another entity. Plain - // values rather than the per-receiver getters `schema.ts` installs: a union - // dispatches on its members, so a subclass of it must not rebind anything. + // `z.object({ member: Member })`, or as a field of another entity const slots = instance as unknown as Record; - for (const slot of ["_zod", "~standard"] as const) { - Object.defineProperty(EntityUnionBase, slot, { - configurable: true, - enumerable: false, - value: slots[slot], - }); - } - - return EntityUnionBase as unknown as EntityUnion; + return { + discriminant, + members, + input, + output, + make, + _zod: slots["_zod"], + "~standard": slots["~standard"], + } as unknown as EntityUnion; } From 8ae10cfb9ab84840c1bab66e614317513fd64732 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 23:48:44 +0200 Subject: [PATCH 2/7] fix: address union value-form review findings - rewrite union()'s stale class-form JSDoc for the value idiom - make the Entity.Instance narrowing test-d assertions non-tautological - fix TS2509 -> TS2507 in the "no class form" test comment - restore non-enumerability of the union's _zod/~standard slots - narrow the return cast with a satisfies check on the real members - add a test for a union composed as a field of another entity --- packages/entity/src/union.spec.ts | 10 ++++++++++ packages/entity/src/union.test-d.ts | 14 ++++++++----- packages/entity/src/union.ts | 31 ++++++++++++++++++++--------- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 3533cab..7d7e49f 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -185,3 +185,13 @@ test("a union is a schema, so it nests", () => { const row = { id: "0199b1f4-1b1e-7000-8000-000000000003", label: "Acme", kind: "business" }; expect(z.array(Account).parse([row])[0]).toBeInstanceOf(Business); }); + +test("a union value composes as a field of another entity, through make", () => { + class Ledger extends Entity("Ledger")({ id: UserId, holder: Account }) {} + const row = { id: "0199b1f4-1b1e-7000-8000-000000000004", label: "Ada", kind: "personal" }; + const ledger = Ledger.make({ + id: "0199b1f4-1b1e-7000-8000-000000000005", + holder: row, + }).getOrThrow(); + expect(ledger.holder).toBeInstanceOf(Personal); +}); diff --git a/packages/entity/src/union.test-d.ts b/packages/entity/src/union.test-d.ts index 09a4772..00372ad 100644 --- a/packages/entity/src/union.test-d.ts +++ b/packages/entity/src/union.test-d.ts @@ -64,17 +64,21 @@ declare const p: Payment; test("a union has no class form", () => { // The class form typed as the members' shared root and could not narrow - // (#57). A class's instance type cannot be a union at all — `TS2509` — - // so the value plus `Entity.Instance` is the only honest spelling. + // (#57). `TS2507` is what fires below; `TS2509` is why it is unfixable — a + // class's instance type cannot be a union at all — so the value plus + // `Entity.Instance` is the only honest spelling. // @ts-expect-error a union is a value, not a constructor class Nope extends Entity.union("kind", [Personal, Business]) {} void Nope; }); test("the const plus Entity.Instance pair narrows to a member", () => { - const onTag: string = p._tag === "Personal" ? p.describe() : p.describe(); + // narrowing on `_tag` reaches the member's own literal `kind` value — + // without it, `p.kind` stays the two-member union and the annotation fails + const onTag: "personal" = p._tag === "Personal" ? p.kind : "personal"; void onTag; - // narrowing on the declared discriminant reaches member-only fields - const onDiscriminant: string = p.kind === "personal" ? p._tag : p._tag; + // narrowing on the declared discriminant reaches the member's own `_tag` — + // without it, `p._tag` stays "Personal" | "Business" and the annotation fails + const onDiscriminant: "Personal" = p.kind === "personal" ? p._tag : "Personal"; void onDiscriminant; }); diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index ad4e8df..7dc98e5 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -97,16 +97,19 @@ const discriminantValues = (member: UnionMember, discriminant: string): readonly * 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. * - * Returns a **class**, so a union is declared the way an entity is: + * Returns a **value**, so a union is declared and named the way any other + * value is: * * ```ts - * class Member extends union("kind", [User, ServiceAccount]) {} + * const Member = union("kind", [User, ServiceAccount]); + * type Member = Entity.Instance; + * * Member.make(row).getOrThrow(); // User | ServiceAccount * ``` * - * As a type, `Member` is the root its members share — or the empty type when - * they share none. `Entity.Instance` is where the exact member - * union lives. The class body holds statics only; the union has no instances. + * `Entity.Instance` is where the exact member union lives — + * there is no class body to hold statics or to narrow through, and nothing is + * ever constructed from `Member` itself. * * `discriminant` names a **declared domain field**, not the entity's `_tag`. * The tag is non-enumerable and absent after serialisation, so a union built @@ -199,13 +202,23 @@ export function union< // the same two slots an entity carries, so a union composes identically — // `z.object({ member: Member })`, or as a field of another entity const slots = instance as unknown as Record; - return { + // checked against the real members, so a mistyped key here is a compile + // error; `_zod`/`~standard`/`__instance` are added below, past what + // `satisfies` can check + const core = { discriminant, members, input, output, make, - _zod: slots["_zod"], - "~standard": slots["~standard"], - } as unknown as EntityUnion; + } satisfies Omit, "__instance" | "_zod" | "~standard">; + // non-enumerable, like `entity.ts` installs `_tag` — so `Object.keys`, + // spread and `JSON.stringify` on the union value don't reach zod's internals + return Object.defineProperties( + { ...core }, + { + _zod: { value: slots["_zod"], enumerable: false, configurable: true }, + "~standard": { value: slots["~standard"], enumerable: false, configurable: true }, + }, + ) as unknown as EntityUnion; } From d20d9978b07c70371749e1467e514b1b1b87e1f3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 23:53:21 +0200 Subject: [PATCH 3/7] test: drop a union-as-a-field test that duplicated an existing one --- packages/entity/src/union.spec.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 7d7e49f..3533cab 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -185,13 +185,3 @@ test("a union is a schema, so it nests", () => { const row = { id: "0199b1f4-1b1e-7000-8000-000000000003", label: "Acme", kind: "business" }; expect(z.array(Account).parse([row])[0]).toBeInstanceOf(Business); }); - -test("a union value composes as a field of another entity, through make", () => { - class Ledger extends Entity("Ledger")({ id: UserId, holder: Account }) {} - const row = { id: "0199b1f4-1b1e-7000-8000-000000000004", label: "Ada", kind: "personal" }; - const ledger = Ledger.make({ - id: "0199b1f4-1b1e-7000-8000-000000000005", - holder: row, - }).getOrThrow(); - expect(ledger.holder).toBeInstanceOf(Personal); -}); From 2b0475b915321fb53302f6c1e7f124a288eef8cf Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 23:56:27 +0200 Subject: [PATCH 4/7] test: declare the billing union as a value --- examples/billing-domain/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index b0c6613..0cd652c 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -87,7 +87,8 @@ export class CreditNote extends BillingDocumentBase.extend("CreditNote")({ * The two mechanisms are not redundant. This field discriminates **data** on * the way in; `P.tag(...)` matches an **instance** you already hold. */ -export class BillingDocument extends Entity.union("kind", [Invoice, CreditNote]) {} +export const BillingDocument = Entity.union("kind", [Invoice, CreditNote]); +export type BillingDocument = Entity.Instance; /* ── Binding the effect sources ──────────────────────────────────────── The package reads no clock and generates no id. A factory is where those From 5844dd44b734fb02ead2e7d03c48d72eee1135d8 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Mon, 10 Aug 2026 00:12:05 +0200 Subject: [PATCH 5/7] docs: a union is a value, and what it adds over a bare type union --- .changeset/union-no-class-form.md | 35 ++++++++++ CLAUDE.md | 37 ++++++----- README.md | 19 ++++-- docs/examples/billing-domain.md | 19 +++--- docs/explanation/unions-and-roots.md | 95 +++++++++++++++------------- docs/how-to/http-contract.md | 3 +- docs/how-to/model-an-aggregate.md | 40 +++++++----- docs/reference/declaration.md | 60 +++++++++++++----- docs/reference/types.md | 19 ++++-- packages/entity/README.md | 10 ++- 10 files changed, 218 insertions(+), 119 deletions(-) create mode 100644 .changeset/union-no-class-form.md diff --git a/.changeset/union-no-class-form.md b/.changeset/union-no-class-form.md new file mode 100644 index 0000000..f074f42 --- /dev/null +++ b/.changeset/union-no-class-form.md @@ -0,0 +1,35 @@ +--- +"@btravstack/entity": minor +--- + +`Entity.union(...)` returns a value, not a class. + +## Breaking: the class form is gone + +```ts +// before +class Payment extends Entity.union("method", [Card, BankTransfer]) {} + +// after +export const Payment = Entity.union("method", [Card, BankTransfer]); +export type Payment = Entity.Instance; +``` + +The class form typed as the members' shared _root_, not as the member union, so +it could not narrow — and it failed late, at the first call site that touched a +member-only field. A class's instance type cannot be a union (`TS2509`), so no +version of it could have narrowed; and its type was always redundant with the +root the author had already named. `class X extends Entity.union(...) {}` is now +`TS2507` at the declaration. + +Statics that lived in the class body become plain functions: + +```ts +export const parsePayment = (row: unknown) => Payment.make(row); +``` + +## Breaking: `__base` is removed + +The phantom `__base` carrier is gone from `EntityStatic` and `UnionMember`. It +existed only to compute the class form's root type. Nothing writes it by hand; +it is listed because it is part of the emitted public surface. diff --git a/CLAUDE.md b/CLAUDE.md index 6255535..d04e4b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,13 +156,17 @@ what they own: makes a schema built from a subclass yield that subclass. - **`union.ts`** — `Entity.union(discriminant, members)`. Dispatches on the declared discriminant rather than trying each branch, so a failing member - reports its own issues. It returns a **class**, so the idiom is - `class Account extends Entity.union("kind", [Personal, Business]) {}` — a - union's class body is for statics, and its constructor defects. A base - constructor may not return a union (TS2509), so `SoleType` claims the root - the members share and falls back to the empty type when they share none; - `Plain` strips that root's abstractness, which a union could never implement. - `Entity.Instance` is where the exact member union lives. + reports its own issues. It returns a **value** with no construct signature, so + the idiom is the pair — + `export const Account = Entity.union("kind", [Personal, Business])` plus + `export type Account = Entity.Instance`, and an entry point is + a plain function beside the const rather than a static. There is no class + form: a class's instance type cannot be a union (**TS2509**), so the class + form could only ever type as the members' shared root, which never narrowed + and was redundant with the root the author had already named — and it failed + late, at the first call site touching a member-only field. Reaching for it is + now **TS2507** at the declaration, pinned by a used `@ts-expect-error` in + `union.test-d.ts`. - **`field.ts`** — `field(schema, flags)`, public as `Entity.field`, and the `FieldSpec` it returns: a plain `{ schema, flags }` record, never a proxy or a subclass, because anything standing in front of an entity-class field breaks @@ -206,15 +210,16 @@ design — `contract.spec.ts` pins that both ways. carry a targeted `oxlint-disable` with a reason — several already exist for `no-catch-all-pattern` where `SchemaIssues` is a single non-union type. - **Comments recording measurements are regression guards.** Many comments - cite a specific TS diagnostic code (TS2344, TS2411, TS2425, TS2509, TS2515, - TS2526, TS4020, TS4111) or a measured library behaviour. The four around roots - and unions: a base constructor may not return a union or a `never`-collapsed - intersection (**TS2509** — `SoleType`, and `RootInstance` widening `_tag` to - `string`), a mapped behaviour type turns a method into a property and breaks - a variant's `override` (**TS2425** — `BehaviourOf`, which must stay unmapped), - and abstractness **does** propagate through the intersection (**TS2515**), - which is why a root's `abstract` member binds every variant and why `Plain` - strips it back off for the union. Verify before "simplifying" them away — the + cite a specific TS diagnostic code (TS2344, TS2411, TS2425, TS2507, TS2509, + TS2515, TS2526, TS4020, TS4111) or a measured library behaviour. The four + around roots and unions: a base constructor may not return a union or a + `never`-collapsed intersection (**TS2509** — why a union has no class form, + and `RootInstance` widening `_tag` to `string`; **TS2507** is what a reader + now hits at the declaration instead), a mapped behaviour type turns a method + into a property and breaks a variant's `override` (**TS2425** — `BehaviourOf`, + which must stay unmapped), and abstractness **does** propagate through the + intersection (**TS2515**), which is why a root's `abstract` member binds every + variant. Verify before "simplifying" them away — the catalog in `pnpm-workspace.yaml` pins `typescript` and `@orpc/zod` to the exact versions those measurements were taken against, with the reason inline. diff --git a/README.md b/README.md index 76876cd..d24f062 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Organization.make({ ...row, name: "" }).match({ An entity is **final**. Fields and behaviour shared by several entities go on a root, `Entity.abstract(name)(fields)`, and extension lives there; a union of -entities is declared as a class: +entities is a value you name: ```ts abstract class AccountBase extends Entity.abstract("Account")({ @@ -165,14 +165,21 @@ class Personal extends AccountBase.extend("Personal")({ } // `Business` is declared the same way, on the same root -class Account extends Entity.union("kind", [Personal, Business]) {} +export const Account = Entity.union("kind", [Personal, Business]); +export type Account = Entity.Instance; Account.make(row); // Result ``` -A variant is a real instance of its root, so `instanceof` narrows to it, and -`Account` used as a type _is_ that root. `Entity.Instance` is -the exact member union. +The const and the type are one declaration in two halves, and both names are +needed: the const is what you call, the type is `Personal | Business`, so +`P.tag(...)` narrows it. A variant is still a real instance of its root, so +`instanceof` narrows to it too and `AccountBase` stays the annotation for "I +only need the shared behaviour". + +There is no class form. Putting the union at a base-class position is `TS2507` +at the declaration, because a class's instance type cannot be a union at all +(`TS2509`). ([Why](https://btravstack.github.io/entity/explanation/unions-and-roots).) ## Documentation @@ -184,7 +191,7 @@ with VitePress from [`docs/`](./docs), and organised by the four - **[Tutorial](https://btravstack.github.io/entity/tutorial/getting-started)** — from nothing to a working entity, one step at a time. - **How-to guides** — [expose an HTTP contract](https://btravstack.github.io/entity/how-to/http-contract) · [persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate) · [model an aggregate](https://btravstack.github.io/entity/how-to/model-an-aggregate) · [test domain logic](https://btravstack.github.io/entity/how-to/test-domain-logic) - **[Reference](https://btravstack.github.io/entity/reference/declaration)** — every member, option and type, with signatures. Plus the [generated API reference](https://btravstack.github.io/entity/api/). -- **[Explanation](https://btravstack.github.io/entity/explanation/why-entity)** — why it is built this way: sealed construction, deep immutability, no I/O, why an entity is final and a union is a class. +- **[Explanation](https://btravstack.github.io/entity/explanation/why-entity)** — why it is built this way: sealed construction, deep immutability, no I/O, why an entity is final and a union has no class form. ## Development diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 653f358..6548d55 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -222,19 +222,16 @@ self-alias still compiles and simply degenerates. ## The union discriminates data, not instances ```ts -export class BillingDocument extends Entity.union("kind", [ - Invoice, - CreditNote, -]) {} +export const BillingDocument = Entity.union("kind", [Invoice, CreditNote]); +export type BillingDocument = Entity.Instance; ``` -A class, not a value: `BillingDocument` is a type as well as a namespace for -statics, and as a type it is `BillingDocumentBase` — the root both members -share. `BillingDocument.make(row)` still returns the exact -`Result` — the spec asserts which class -comes back — and `Entity.Instance` names that union, -which `emit-guards.ts` pins. The body holds statics only; the union has no -instances of its own. +A value, and a type of the same name beside it. `BillingDocument.make(row)` +returns `Result` — the spec asserts which +class comes back — and the type is that same `Invoice | CreditNote`, which +`emit-guards.ts` pins. There is no class form to reach for: putting the union at +a base-class position is `TS2507` at the declaration, because a class's instance +type cannot be a union at all (`TS2509`). `kind` is a **declared domain field** — `z.literal("INVOICE")` on one member and `z.literal("CREDIT_NOTE")` on the other, both flagged `generated` so no caller diff --git a/docs/explanation/unions-and-roots.md b/docs/explanation/unions-and-roots.md index 36f8624..afec31b 100644 --- a/docs/explanation/unions-and-roots.md +++ b/docs/explanation/unions-and-roots.md @@ -1,12 +1,12 @@ --- title: Unions and roots -description: Why a union's class type is the members' shared root rather than the member union, why an abstract root carries no tag, and what survives the intersection that builds a variant. +description: Why a union is a value with no class form, why an abstract root carries no tag, and what survives the intersection that builds a variant. --- # Unions and roots -Two of the declarations in this package are classes you extend rather than -values you hold: +One declaration in this package is a class you extend, and one is a value you +hold: ```ts abstract class AccountBase extends Entity.abstract("Account")({ @@ -14,66 +14,72 @@ abstract class AccountBase extends Entity.abstract("Account")({ label: Label, }) {} -class Account extends Entity.union("kind", [Personal, Business]) {} +export const Account = Entity.union("kind", [Personal, Business]); +export type Account = Entity.Instance; ``` -Both shapes were forced by what TypeScript accepts at a base-class position. -This page is the reasoning; [Declaring an entity](/reference/declaration) is the -surface. +Which is which was settled by what TypeScript accepts at a base-class position: +a root can sit there, and a union cannot. This page is the reasoning; +[Declaring an entity](/reference/declaration) is the surface. -## Why a union's type is its members' root, not its members +## Why a union has no class form -A base-constructor return type may not be a union. Claiming one fails with +`Entity.union` returns a value with no construct signature, so reaching for the +class form is an error at the declaration itself: + +``` +TS2507: Type 'EntityUnion<"kind", readonly [typeof Personal, typeof Business]>' +is not a constructor function type +``` + +It used to compile, which is what made it worth removing. A base-constructor +return type may not be a union — claiming one fails with ``` TS2509: Base constructor return type 'Personal | Business' is not an object type or intersection of object types with statically known members ``` -so `Entity.union` cannot describe its class as the thing its `make` returns. -What it claims instead is the **root its members share** — one object type, -which the rule accepts. Members that do not share one claim the empty type -instead, rather than a supertype that does not exist. +so no class form could ever have typed as the thing its `make` returns. What it +typed as instead was the **root its members share**: one object type, which the +rule accepts. -"Share one" is read off the `extend` call, not off the inheritance graph, and -the difference is easy to walk into. Members extended from an **intermediate** -root carry that intermediate's instance type, so +That left the class form both redundant and treacherous. -```ts -class Personal extends AccountBase.extend("Personal")({ … }) {} -class Business extends Auditable.extend("Business")({ … }) {} // Auditable extends AccountBase -``` +Redundant, because the root is a class the author has already declared and +named. A type that is either `AccountBase` — the name already in scope — or, for +members sharing no root, the empty type, adds nothing you could not write +yourself. -do have `AccountBase` in common, and are still not one type: `Personal` claims -`AccountBase`, `Business` claims `Auditable`, the two do not reduce to a single -object type, and `Entity.union("kind", [Personal, Business])` is the empty type. -Extending every member of a union from the **same** class is what keeps the -union's type useful; `Entity.Instance` is unaffected either way. +Treacherous, because it failed **late**. The declaration compiled clean; so did +every line touching only shared fields. The error surfaced at the first call +site reading a member-only field off a value annotated with the union's name, +which is arbitrarily far from the declaration that caused it. `TS2507` fires +where the mistake is written. -The exact union is not lost, only spelled elsewhere: +`Entity.Instance` is where the member union lives, and the +`export type Account = …` line beside the const is what puts it under the name a +reader expects: ```ts -class Account extends Entity.union("kind", [Personal, Business]) {} +export const Account = Entity.union("kind", [Personal, Business]); +export type Account = Entity.Instance; // Personal | Business -declare const account: Account; // AccountBase — the shared root -type AnyAccount = Entity.Instance; // Personal | Business +declare const account: Account; +account._tag; // "Personal" | "Business" — P.tag(...) narrows it ``` -`Account.make(row)` is unaffected: it returns -`Result`, and each instance carries its own -`_tag`, so `P.tag(...)` narrows it. The narrowing is only absent from the class -name used as an annotation. +`AccountBase` is still the right annotation when a function needs only the +shared behaviour, and a variant is a real instance of it, so `instanceof` +narrows too. Neither name is a fallback for the other. ## Why a union has no instances `make` dispatches on the discriminant and constructs a **member**, so nothing is -ever an instance of the union itself. A union's class body therefore holds -statics — an instance method written there could never reach a member, which is -why reaching the constructor is a defect rather than an `InvalidEntity`: - -``` -Invoice | CreditNote: a union has no instances — use make() -``` +ever an instance of the union itself. The value form makes that structural +rather than a rule to remember: there is no constructor to reach, and no class +body in which to write an instance method that could never run. An entry point +that would once have been a static is a plain function beside the const. ## Why the root carries no tag @@ -122,11 +128,10 @@ class Forgot extends AccountBase.extend("Forgot")({ That is what makes a root a place to state a contract and not only a place to share fields. -The union is the deliberate exception: it strips the abstractness back off. -It has to. A union has no instances, so inheriting the root's abstract members -would demand implementations from a class body that can never be constructed — -`class Account extends Entity.union("kind", [Personal, Business]) {}` would -itself fail with TS2515, for a method that could never run. +A union never inherits that obligation, and no longer needs anything to strip it +back off. It is a value, not a class, so there is no class body for an +`abstract` member to bind to, and nothing to implement it with — a union has no +instances. The obligation stays on the variants, which have them. ## What a root cannot take over diff --git a/docs/how-to/http-contract.md b/docs/how-to/http-contract.md index ff05a71..f5a576a 100644 --- a/docs/how-to/http-contract.md +++ b/docs/how-to/http-contract.md @@ -120,7 +120,8 @@ to attach the message to a form field or to the form. polymorphic endpoint keeps its contract: ```ts -class Member extends Entity.union("kind", [User, ServiceAccount]) {} +export const Member = Entity.union("kind", [User, ServiceAccount]); +export type Member = Entity.Instance; const Body = Member.input; // z.discriminatedUnion("kind", [...]) z.toJSONSchema(Body, { io: "input" }); // one branch per member diff --git a/docs/how-to/model-an-aggregate.md b/docs/how-to/model-an-aggregate.md index 55603f2..0aba93f 100644 --- a/docs/how-to/model-an-aggregate.md +++ b/docs/how-to/model-an-aggregate.md @@ -127,7 +127,8 @@ class ServiceAccount extends MemberBase.extend("ServiceAccount")({ } } -class Member extends Entity.union("kind", [User, ServiceAccount]) {} +export const Member = Entity.union("kind", [User, ServiceAccount]); +export type Member = Entity.Instance; Member.make(row).getOrThrow(); // User | ServiceAccount — the real class ``` @@ -136,21 +137,26 @@ The discriminant is an ordinary declared field. The root is what lets the two variants share `id` and the `label()` contract — declaring `abstract label()` there makes a variant that forgets it a compile error, not a runtime surprise. -## Put statics, not methods, in the union's body +## Put entry points beside the union, not on it Nothing is ever an instance of a union: `make` dispatches to a member and -constructs **that** class. An instance method written in the union's body could -never reach a member, and `new Member(...)` is a defect. Statics are what the -body is for — the same declaration, with an entry point on it: +constructs **that** class. `Entity.union` returns a value, so there is no class +body to put an unreachable instance method in — and no class body to hang a +static off either. An entry point is a plain function next to the const: ```ts -class Member extends Entity.union("kind", [User, ServiceAccount]) { - static fromRow(row: unknown) { - return Member.make(row); - } -} +export const Member = Entity.union("kind", [User, ServiceAccount]); +export type Member = Entity.Instance; + +export const memberFromRow = (row: unknown) => Member.make(row); ``` +If you are migrating from the class form, this is the one change that is not +mechanical: `static fromRow` in the old body becomes `memberFromRow` here, and +the call sites lose the `Member.` prefix. Everything else — `make`, `input`, +`output`, `members`, `discriminant` — is reached off the const exactly as it was +reached off the class. + The union dispatches on the discriminant rather than trying each branch, so a member whose own validation fails reports _its_ issues rather than every branch's. A payload whose discriminant matches no member fails as an @@ -174,16 +180,18 @@ class Audit extends Entity("Audit")({ id: AuditId, actor: Member }) {} ## Name what comes back -`Member` as a **type** is `MemberBase`, the root its members share — a base -class cannot be a union type, so that is what the class can claim. Ask for the -exact union by name instead: +`Member` as a **type** is `User | ServiceAccount` — the `export type` line +beside the const, which is why both halves of the pair are written. `MemberBase` +is the other annotation worth naming: ```ts -type AnyMember = Entity.Instance; // User | ServiceAccount +declare function render(member: Member): string; // needs a variant's own fields +declare function idOf(member: MemberBase): MemberId; // needs only the shared half ``` -Either annotation is usable: the root gives you `label()` and `id`, the member -union gives you each variant's own fields. +Both are usable and they are not interchangeable: the root gives you `label()` +and `id` for any variant present or future, the member union gives you each +variant's own fields and narrows under `P.tag(...)`. ([Why the two differ](/explanation/unions-and-roots).) ## Match exhaustively on what comes back diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index b999d21..8f2dd77 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -381,15 +381,12 @@ matters. ## `Entity.union(discriminant, members)` -A union of entities, declared as a class. +A union of entities. `Entity.union` returns a **value**, so a union is declared +the way any other value is — a `const`, and a type of the same name beside it: ```ts -class Account extends Entity.union("kind", [Personal, Business]) { - /** a union's class body is for statics — it has no instances */ - static parse(row: unknown) { - return Account.make(row); - } -} +export const Account = Entity.union("kind", [Personal, Business]); +export type Account = Entity.Instance; Account.make(row); // Result Account.input; // discriminated union, one branch per member @@ -398,19 +395,29 @@ Account.members; // the tuple, for registries and exhaustiveness Account.discriminant; // "kind" ``` -As a **type**, `Account` is the root its members share — `AccountBase` above — -or the empty type when they share none. The exact member union is -`Entity.Instance`; `make` returns it either way. -([Why](/explanation/unions-and-roots#why-a-union-s-type-is-its-members-root-not-its-members).) +Both halves are needed and they say different things: the const is what you +call, and the type is the exact member union — `Personal | Business`, which +`P.tag(...)` and the discriminant both narrow. + +There is no class body, so an entry point that would once have been a static is +a plain function beside the const: + +```ts +export const parseAccount = (row: unknown) => Account.make(row); +``` -`new Account(...)` does not compile, and reaching the constructor at runtime is -a defect — `make` dispatches to a member, so nothing is ever an instance of the -union: +Reaching for the class form fails at the declaration, not at a later call site: ``` -Personal | Business: a union has no instances — use make() +TS2507: Type 'EntityUnion<"kind", readonly [typeof Personal, typeof Business]>' +is not a constructor function type ``` +A second code is the reason it is unfixable rather than unimplemented: a class's +instance type may not be a union (`TS2509`), so the class form could only ever +type as the members' shared root, which never narrowed to a member. +([Why](/explanation/unions-and-roots#why-a-union-has-no-class-form).) + `discriminant` names a declared domain field, not `_tag`. The union dispatches on it rather than trying each branch, so a failing member reports its own issues. The union is a schema too, so it nests as a field. @@ -430,3 +437,26 @@ Entity.union("kind", [User, AlsoUser]); `_tag` cannot serve as the discriminant here, and that is not an oversight — [it never reaches the wire](/explanation/tags-and-identity). + +### When to declare a union and when to write one by hand + +`Entity.Instance` **is** `Personal | Business`, so the type is +not what a union buys you. Everything else it carries is: + +| You need | `Entity.union` gives | +| ---------------------------------------------- | --------------------------------------------------------- | +| a member out of untrusted input | `make(unknown)`, dispatching on the declared discriminant | +| the union as a field of another entity | it is a schema — `Entity("Audit")({ actor: Account })` | +| a contract, or JSON Schema in either direction | `input` and `output`, real `z.discriminatedUnion`s | +| a failure a caller can act on | the matched member's own issues, not every branch's | +| two members claiming one value caught | a defect at declaration time, naming both members | +| a registry, or an exhaustiveness check | `members` and `discriminant`, reachable at runtime | + +The type is also **derived**. Add a third variant to `members` and +`Entity.Instance` follows it; a hand-written +`Personal | Business` stays two members wide, keeps compiling, and drifts +silently — which is the failure the derivation exists to prevent. + +So write the bare union by hand when you only need to _name_ two entities in a +signature, and nothing parses, nests, serialises or reports. Declare an +`Entity.union` the moment any of those enter the picture. diff --git a/docs/reference/types.md b/docs/reference/types.md index 0d487e1..f98a20f 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -19,21 +19,26 @@ type OrgPatch = Entity.Patch; // what update() accepts ## `Entity.Instance` -The instance type of an entity **or a union** — for a union, the exact member -union, which the class name as a type is not -([why](/explanation/unions-and-roots#why-a-union-s-type-is-its-members-root-not-its-members)): +The instance type of an entity **or a union**. `Entity.union` returns a value, +so for a union this is the only spelling there is — there is no class name to +use as a type +([why](/explanation/unions-and-roots#why-a-union-has-no-class-form)): ```ts -class Account extends Entity.union("kind", [Personal, Business]) {} +export const Account = Entity.union("kind", [Personal, Business]); +export type Account = Entity.Instance; // Personal | Business -type AnyAccount = Entity.Instance; // Personal | Business type OnePersonal = Entity.Instance; // Personal ``` +The `export type` line beside the const is the idiom: it puts the member union +under the name a reader already expects, and a value and a type may share one +name in TypeScript. + It is read off the declaration, so it cannot drift out of step with the members the way a hand-written `InstanceType | InstanceType` silently can. The result narrows under `P.tag(...)` like any other -union of entities. +Business>` silently can. The result narrows under `P.tag(...)`, and under the +declared discriminant, like any other union of entities. ## The other namespace members diff --git a/packages/entity/README.md b/packages/entity/README.md index 1e22b5c..cefd52d 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -72,7 +72,7 @@ const renamed = loaded.update({ name: next }).getOrThrow(); // a NEW entity An entity is **final**. Fields and behaviour shared by several entities go on a root, `Entity.abstract(name)(fields)`, and extension lives there; a union of -entities is declared as a class: +entities is a value you name: ```ts abstract class AccountBase extends Entity.abstract("Account")({ @@ -91,11 +91,17 @@ class Personal extends AccountBase.extend("Personal")({ } // `Business` is declared the same way, on the same root -class Account extends Entity.union("kind", [Personal, Business]) {} +export const Account = Entity.union("kind", [Personal, Business]); +export type Account = Entity.Instance; Account.make(row); // Result ``` +A variant is a real instance of its root, so `instanceof` narrows to it, and +`Account` as a type is `Personal | Business`. There is no class form: putting +the union at a base-class position is `TS2507` at the declaration, because a +class's instance type cannot be a union at all (`TS2509`). + ## Documentation **[btravstack.github.io/entity](https://btravstack.github.io/entity/)** From 509e4ff68a608615c6453a97edaaca675aef9014 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Mon, 10 Aug 2026 00:24:46 +0200 Subject: [PATCH 6/7] fix: address union no-class-form final review findings Five scoped fixes from the whole-branch review: a dangling AnyMember reference in the aggregate how-to, a false JSON.stringify claim in union.ts's non-enumerability comment (corrected and now guarded by a test), a redundant object spread before Object.defineProperties, a test-d.ts fixture renamed from Payment to Account to match the rest of the repo, and dead test-d.ts scaffolding replaced with a restored type-level pin on a union's inherited root behaviour. --- docs/how-to/model-an-aggregate.md | 2 +- packages/entity/src/union.spec.ts | 5 +++++ packages/entity/src/union.test-d.ts | 17 +++++++++++------ packages/entity/src/union.ts | 17 ++++++++--------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/docs/how-to/model-an-aggregate.md b/docs/how-to/model-an-aggregate.md index 0aba93f..9e18411 100644 --- a/docs/how-to/model-an-aggregate.md +++ b/docs/how-to/model-an-aggregate.md @@ -197,7 +197,7 @@ variant's own fields and narrows under `P.tag(...)`. ## Match exhaustively on what comes back ```ts -const describe = (m: AnyMember) => +const describe = (m: Member) => match(m) .with(P.tag("User"), (u) => `user:${u.email}`) .with(P.tag("ServiceAccount"), (s) => `svc:${s.name}`) diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index 3533cab..d6cd227 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -95,6 +95,11 @@ test("the members are reachable, for exhaustiveness and registries", () => { expect(Member.members.map((m) => m.entityName)).toEqual(["User", "ServiceAccount"]); }); +test("the union value's own enumerable keys are exactly the five public ones", () => { + expect(Object.keys(Member)).toEqual(["discriminant", "members", "input", "output", "make"]); + expect(Object.keys({ ...Member })).toEqual(Object.keys(Member)); +}); + test("a union member can itself be a field of another entity", () => { class Audit extends Entity("Audit")({ id: UserId, actor: Member }) {} const a = Audit.make({ id: userRow.id, actor: svcRow }).getOrThrow(); diff --git a/packages/entity/src/union.test-d.ts b/packages/entity/src/union.test-d.ts index 00372ad..a919aa5 100644 --- a/packages/entity/src/union.test-d.ts +++ b/packages/entity/src/union.test-d.ts @@ -42,9 +42,6 @@ const AcctId = z.uuid().brand("AcctId"); abstract class AccountBase extends Entity.abstract("Account")({ id: AcctId, label: Label }) { abstract describe(): string; - get slug(): string { - return this.label.toLowerCase(); - } } class Personal extends AccountBase.extend("Personal")({ kind: z.literal("personal") }) { override describe(): string { @@ -57,10 +54,10 @@ class Business extends AccountBase.extend("Business")({ kind: z.literal("busines } } -const Payment = Entity.union("kind", [Personal, Business]); -type Payment = Entity.Instance; +const Account = Entity.union("kind", [Personal, Business]); +type Account = Entity.Instance; -declare const p: Payment; +declare const p: Account; test("a union has no class form", () => { // The class form typed as the members' shared root and could not narrow @@ -82,3 +79,11 @@ test("the const plus Entity.Instance pair narrows to a member", () => { const onDiscriminant: "Personal" = p.kind === "personal" ? p._tag : "Personal"; void onDiscriminant; }); + +test("a union's instance type still carries the root's behaviour", () => { + // pins, at the type level, that Account (Personal | Business) keeps + // AccountBase's abstract `describe` — the class-form test for this was + // deleted with #57; `union.spec.ts` only exercises it at runtime + const described: string = p.describe(); + void described; +}); diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index 7dc98e5..402a9eb 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -212,13 +212,12 @@ export function union< output, make, } satisfies Omit, "__instance" | "_zod" | "~standard">; - // non-enumerable, like `entity.ts` installs `_tag` — so `Object.keys`, - // spread and `JSON.stringify` on the union value don't reach zod's internals - return Object.defineProperties( - { ...core }, - { - _zod: { value: slots["_zod"], enumerable: false, configurable: true }, - "~standard": { value: slots["~standard"], enumerable: false, configurable: true }, - }, - ) as unknown as EntityUnion; + // non-enumerable, like `entity.ts` installs `_tag` — so `Object.keys` and + // spread over the union value list only the five public members; `input` + // and `output` are themselves enumerable ZodTypes, so this does not keep + // `JSON.stringify` from walking the whole schema graph (measured) + return Object.defineProperties(core, { + _zod: { value: slots["_zod"], enumerable: false, configurable: true }, + "~standard": { value: slots["~standard"], enumerable: false, configurable: true }, + }) as unknown as EntityUnion; } From 4adb376c4a7cdeee3d83c02625b902ad8751e71b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Mon, 10 Aug 2026 00:42:37 +0200 Subject: [PATCH 7/7] docs: say why each key is omitted from the union's satisfies target --- packages/entity/src/union.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index 402a9eb..639bb2d 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -203,8 +203,9 @@ export function union< // `z.object({ member: Member })`, or as a field of another entity const slots = instance as unknown as Record; // checked against the real members, so a mistyped key here is a compile - // error; `_zod`/`~standard`/`__instance` are added below, past what - // `satisfies` can check + // error. The three omitted keys are omitted for two different reasons: + // `_zod`/`~standard` are installed below, and `__instance` is a type-level + // carrier that never exists at runtime. const core = { discriminant, members,