From ad7a5ba6b5e5e1504acc5e1b23709d4edbd94982 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 17:23:17 +0200 Subject: [PATCH 01/12] refactor: move extend and its declaration record into base.ts --- packages/entity/src/base.ts | 89 +++++++++++++++++++++++++++++++++++ packages/entity/src/entity.ts | 53 ++------------------- 2 files changed, 93 insertions(+), 49 deletions(-) create mode 100644 packages/entity/src/base.ts diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts new file mode 100644 index 0000000..edc5924 --- /dev/null +++ b/packages/entity/src/base.ts @@ -0,0 +1,89 @@ +import type { Invariant } from "./invariant.js"; +import type { Fields } from "./types.js"; + +/** The entity builder, loosened. Passed in so this module never imports it. */ +export type BuildEntity = ( + tag: string, +) => (fields: Fields, options?: unknown) => { prototype: object }; + +/** + * What each declaration was made with, so `extend` can rebuild from it. Keyed + * by the class rather than stored on it, so nothing leaks onto the public + * surface or into a consumer's declarations. + */ +const declarations = new WeakMap< + object, + { readonly fields: Fields; readonly options: Record | undefined } +>(); + +export const record = ( + target: object, + fields: Fields, + options: Record | undefined, +): void => { + declarations.set(target, { fields, options }); +}; + +/** + * The nearest declaration up the receiver's *static* chain. + * + * Walked rather than read directly, because an abstract root is extended + * through the user's own subclass — `AccountBase.extend(...)` has `this` set to + * a class the record was never keyed by. + */ +const declarationOf = (receiver: object) => { + let ctor: object | null = receiver; + while (ctor !== null) { + const found = declarations.get(ctor); + if (found !== undefined) return found; + ctor = Object.getPrototypeOf(ctor) as object | null; + } + return undefined; +}; + +/** + * Options merge per key, child winning — except `invariants`, which + * **concatenates** parent-then-child. Inheriting matters more than it might + * look: silently dropping the parent's `immutable` or `invariants` would leave + * the extension quietly laxer than what it extends. An extension can add rules; + * it cannot shed them. + */ +const rebuild = ( + buildEntity: BuildEntity, + receiver: object, + nextTag: string, + nextFields: Fields, + nextOptions: Record | undefined, +): { prototype: object } => { + const parent = declarationOf(receiver); + const parentOptions = parent?.options as + | { readonly invariants?: readonly Invariant[] } + | undefined; + const childInvariants = ( + nextOptions as { readonly invariants?: readonly Invariant[] } | undefined + )?.invariants; + const invariants = [...(parentOptions?.invariants ?? []), ...(childInvariants ?? [])]; + return buildEntity(nextTag)( + { ...parent?.fields, ...nextFields }, + { + ...parent?.options, + ...nextOptions, + ...(invariants.length > 0 ? { invariants } : {}), + }, + ); +}; + +/** + * A *new* entity carrying this one's fields plus more, under its own tag. + * + * Not subclassing, which stays forbidden: the result is its own `Entity(...)` + * call, so it has a distinct tag, a distinct identity under `equals`, and its + * own schemas. + */ +export const defineExtend = (target: object, buildEntity: BuildEntity): void => { + Object.defineProperty(target, "extend", { + enumerable: false, + value: (nextTag: string) => (nextFields: Fields, nextOptions?: Record) => + rebuild(buildEntity, target, nextTag, nextFields, nextOptions), + }); +}; diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index f8ea3e4..f604bed 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -2,6 +2,8 @@ import { fromSchema, type SchemaIssues } from "@unthrown/standard-schema"; import { Err, Ok, P, all, fromPromise, fromThrowable, type Result } from "unthrown"; import type { z } from "zod"; +import type { BuildEntity } from "./base.js"; +import { defineExtend, record } from "./base.js"; import { computed, type ComputedField } from "./computed.js"; import { deepEqual } from "./equal.js"; import { InvalidEntity } from "./errors.js"; @@ -44,16 +46,6 @@ const resolveAll = async ( const maskOf = (keys: readonly PropertyKey[]) => Object.fromEntries(keys.map((k) => [k, true as const])); -/** - * What each entity was declared with, so `extend` can rebuild from it. Keyed - * by the base class rather than stored on it, so nothing leaks onto the - * public surface or into a consumer's declarations. - */ -const declarations = new WeakMap< - object, - { readonly fields: Fields; readonly options: Record | undefined } ->(); - /** * `class X extends Entity("X")({ …fields }) {}` * @@ -432,45 +424,8 @@ export function Entity(tag: Tag) { } attachSchema>(Base, input); - declarations.set(Base, { fields, options: options as Record | undefined }); - - /** - * A *new* entity carrying this one's fields plus more, under its own tag. - * - * Not subclassing, which stays forbidden: the result is its own - * `Entity(...)` call, so it has a distinct tag, a distinct identity under - * `equals`, and its own schemas. `class X extends Parent {}` would have - * had none of those — same fields, same tag, no way to tell the two apart. - * - * Options merge per key, child winning — except `invariants`, which - * **concatenates** parent-then-child. Inheriting matters more than it might - * look: silently dropping the parent's `immutable` or `invariants` would - * leave the extension quietly laxer than what it extends, and child-wins on - * a list of rules is exactly how that happens. An extension can add rules; - * it cannot shed them. Chained extends compose without duplicating, because - * each `extend` stores the list it already merged. - */ - Object.defineProperty(Base, "extend", { - enumerable: false, - value: (nextTag: string) => (nextFields: Fields, nextOptions?: Record) => { - const parent = declarations.get(Base); - const parentOptions = parent?.options as - | { readonly invariants?: readonly Invariant[] } - | undefined; - const childInvariants = ( - nextOptions as { readonly invariants?: readonly Invariant[] } | undefined - )?.invariants; - const invariants = [...(parentOptions?.invariants ?? []), ...(childInvariants ?? [])]; - return (Entity as (t: string) => (f: Fields, o?: unknown) => unknown)(nextTag)( - { ...parent?.fields, ...nextFields }, - { - ...parent?.options, - ...nextOptions, - ...(invariants.length > 0 ? { invariants } : {}), - }, - ); - }, - }); + record(Base, fields, options as Record | undefined); + defineExtend(Base, Entity as unknown as BuildEntity); return Base as unknown as EntityStatic; }; From 8cfc486c48e5c7cede487d02fe8f11e9e7a8b42c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 17:33:52 +0200 Subject: [PATCH 02/12] feat: add Entity.abstract, a tagless root that carries shared behaviour --- docs/typedoc.json | 3 + packages/entity/src/base.spec.ts | 111 +++++++++++++++++++++++++++++ packages/entity/src/base.test-d.ts | 91 +++++++++++++++++++++++ packages/entity/src/base.ts | 67 ++++++++++++++++- packages/entity/src/entity.ts | 27 ++++++- packages/entity/src/index.ts | 5 ++ packages/entity/src/types.ts | 107 +++++++++++++++++++++++++-- 7 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 packages/entity/src/base.spec.ts create mode 100644 packages/entity/src/base.test-d.ts diff --git a/docs/typedoc.json b/docs/typedoc.json index 012b7be..e8641b5 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -5,9 +5,11 @@ "out": "api/entity", "categoryOrder": ["Facade", "Declaration", "Entry points", "Types", "Errors", "*"], "intentionallyNotExported": [ + "AbstractEntitySrc", "AsyncEntityFactory", "AsyncGenerators", "BaseInstanceSrc", + "BehaviourOf", "ComputedField", "ComputedFieldSrc", "ComputedOf", @@ -30,6 +32,7 @@ "OnlyNominal", "OutputOf", "PatchOf", + "RootInstance", "SealedSrc", "UpdateInputShapeOf" ] diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts new file mode 100644 index 0000000..0f60b31 --- /dev/null +++ b/packages/entity/src/base.spec.ts @@ -0,0 +1,111 @@ +import { P } from "unthrown"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const AccountId = z.uuid().brand("AccountId"); +const Label = z.string().min(1).brand("Label"); +const Upper = z.string().min(1).brand("Upper"); + +abstract class AccountBase extends Entity.abstract("Account")( + { id: AccountId, label: Label }, + { + immutable: ["id"], + computed: { + shout: Entity.computed(Upper, (d) => d.label.toUpperCase() as z.infer), + }, + invariants: [Entity.invariant((d) => d.label.length <= 20, "label must be at most 20 chars")], + }, +) { + abstract describe(): string; + get slug(): string { + return this.label.toLowerCase(); + } +} + +/** Behaviour-only intermediate root: adds no fields, only methods. */ +abstract class Auditable extends AccountBase { + audit(): string { + return `${this._tag}:${this.id}`; + } +} + +class Personal extends AccountBase.extend("Personal")({ kind: z.literal("personal") }) { + override describe(): string { + return `personal ${this.slug}`; + } +} + +class Business extends Auditable.extend("Business")({ + kind: z.literal("business"), + vat: Label, +}) { + override describe(): string { + return `business ${this.vat}`; + } +} + +const id = "0199b1f4-1b1e-7000-8000-000000000000"; +const personal = () => Personal.make({ id, label: "Ada", kind: "personal" }).getOrThrow(); + +test("a variant carries the root's fields plus its own", () => { + const p = personal(); + expect(p.id).toBe(id); + expect(p.label).toBe("Ada"); + expect(p.kind).toBe("personal"); + expect(p._tag).toBe("Personal"); +}); + +test("the root's behaviour runs on a variant instance", () => { + expect(personal().slug).toBe("ada"); + expect(personal().describe()).toBe("personal ada"); +}); + +test("a variant is an instance of its root", () => { + // chaining, not copying: the root is a real runtime supertype, so + // `instanceof` narrows and a shared base can be tested against + expect(personal()).toBeInstanceOf(AccountBase); + expect(personal()).not.toBeInstanceOf(Business); +}); + +test("a behaviour-only intermediate root is picked up", () => { + const b = Business.make({ id, label: "Acme", kind: "business", vat: "FR1" }).getOrThrow(); + expect(b.audit()).toBe("Business:" + id); + expect(b.slug).toBe("acme"); +}); + +test("the root's options carry over", () => { + expect(Object.keys(Personal.updateInput.shape).toSorted()).toEqual(["kind", "label"]); + expect(personal().shout).toBe("ADA"); + expect(Personal.make({ id, label: "x".repeat(21), kind: "personal" }).isErr()).toBe(true); +}); + +test("an entity's own toJSON/equals/update shadow a root's", () => { + abstract class Shadowing extends Entity.abstract("Shadowing")({ id: AccountId }) { + override equals(): boolean { + return true; + } + } + class Shadowed extends Shadowing.extend("Shadowed")({ label: Label }) {} + const a = Shadowed.make({ id, label: "a" }).getOrThrow(); + const b = Shadowed.make({ id, label: "b" }).getOrThrow(); + // the root sits *below* the entity's own prototype in the chain, so a root + // can call these three but never override them + expect(a.equals(b)).toBe(false); +}); + +test("a root has no instances", () => { + const Ctor = AccountBase as unknown as new () => unknown; + expect(() => new Ctor()).toThrow(/no instances/); +}); + +test("a variant is still sealed and still refuses a bare subclass", () => { + class Sub extends Personal {} + const outcome = Sub.make({ id, label: "Ada", kind: "personal" }).match({ + ok: () => "WRONGLY ACCEPTED", + errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), + defect: () => "defect", + }); + expect(outcome).toBe("defect"); +}); diff --git a/packages/entity/src/base.test-d.ts b/packages/entity/src/base.test-d.ts new file mode 100644 index 0000000..b3285c8 --- /dev/null +++ b/packages/entity/src/base.test-d.ts @@ -0,0 +1,91 @@ +import { match, P } from "unthrown"; +import { test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const AccountId = z.uuid().brand("AccountId"); +const Label = z.string().min(1).brand("Label"); + +abstract class AccountBase extends Entity.abstract("Account")( + { id: AccountId, label: Label }, + { immutable: ["id"] }, +) { + abstract describe(): string; + get slug(): string { + return this.label.toLowerCase(); + } + relabel(next: z.infer) { + return this.update({ label: next }); + } +} + +class Personal extends AccountBase.extend("Personal")({ kind: z.literal("personal") }) { + // An `override` of an abstract member is the M3 regression guard: if the + // behaviour type is ever built with `Omit` or a key-remapped mapped type, + // `describe` arrives as a function-typed *property* and this line fails with + // TS2425. + override describe(): string { + return `personal ${this.slug}`; + } +} + +class Business extends AccountBase.extend("Business")({ + kind: z.literal("business"), + vat: Label, +}) { + override describe(): string { + return `business ${this.vat}`; + } +} + +test("a variant's instance carries the root's behaviour and its own fields", () => { + const p = Personal.make({}).getOrThrow(); + const slug: string = p.slug; + const described: string = p.describe(); + const label: z.infer = p.label; + const kind: "personal" = p.kind; + const tag: "Personal" = p._tag; + void slug; + void described; + void label; + void kind; + void tag; + // @ts-expect-error the variant's data is still read-only + p.label = label; +}); + +test("update from the root's body and from outside both yield the variant", () => { + const p = Personal.make({}).getOrThrow(); + const renamed: typeof p = p.relabel("x" as z.infer).getOrThrow(); + const patched: typeof p = p.update({ label: "x" as z.infer }).getOrThrow(); + void renamed; + void patched; + // @ts-expect-error `id` is immutable on the root, and the variant inherits that + p.update({ id: p.id }); +}); + +test("Entity.Instance recovers the instance type", () => { + const p: Entity.Instance = Personal.make({}).getOrThrow(); + const described: string = match(p) + .with(P.tag("Personal"), (x) => x.describe()) + .exhaustive(); + void described; +}); + +test("a root is not an entity", () => { + // @ts-expect-error a root has no `make` — it is not an entity + AccountBase.make({}); + // @ts-expect-error a root has no schema members either + void AccountBase.input; +}); + +test("a root enforces the same field rules as a fresh declaration", () => { + AccountBase.extend("Ok")({ ok: Label }); + // @ts-expect-error an unbranded field is rejected, exactly as in Entity(...) + AccountBase.extend("Unbranded")({ plain: z.string() }); + // @ts-expect-error a reserved name is rejected, exactly as in Entity(...) + AccountBase.extend("Reserved")({ update: Label }); +}); + +void Business; diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts index edc5924..24ca948 100644 --- a/packages/entity/src/base.ts +++ b/packages/entity/src/base.ts @@ -1,5 +1,7 @@ +import type { ComputedField } from "./computed.js"; import type { Invariant } from "./invariant.js"; -import type { Fields } from "./types.js"; +import type { OnlyNominal } from "./shape.js"; +import type { AbstractEntity, Fields, InputOf, OutputOf } from "./types.js"; /** The entity builder, loosened. Passed in so this module never imports it. */ export type BuildEntity = ( @@ -87,3 +89,66 @@ export const defineExtend = (target: object, buildEntity: BuildEntity): void => rebuild(buildEntity, target, nextTag, nextFields, nextOptions), }); }; + +/** + * `extend` on a root: the same rebuild, plus the new base's prototype rewired + * onto the receiver's. + * + * Chaining rather than copying descriptors, for three reasons: `personal + * instanceof AccountBase` becomes true, so the root is a real runtime + * supertype; a behaviour-only intermediate root is picked up without any + * bookkeeping; and the entity's own `toJSON`/`equals`/`update` stay own + * members of the child's prototype, so they shadow anything the root declares + * under those names. + */ +const defineRootExtend = (Root: object, buildEntity: BuildEntity): void => { + Object.defineProperty(Root, "extend", { + enumerable: false, + // `this`, not a `const receiver = this` alias: the returned arrow captures + // the method's `this` lexically, so the alias only trips `no-this-alias`. + value(this: { readonly prototype: object }, nextTag: string) { + return (nextFields: Fields, nextOptions?: Record) => { + const child = rebuild(buildEntity, this, nextTag, nextFields, nextOptions); + Object.setPrototypeOf(child.prototype, this.prototype); + return child; + }; + }, + }); +}; + +/** + * `Entity.abstract`, built against the entity builder rather than importing it, + * so this module stays free of a cycle. + */ +export const createBase = + (buildEntity: BuildEntity) => + (name: Name) => + < + S extends Fields, + A extends Fields = Record, + const G extends readonly (keyof S)[] = [], + const I extends readonly (keyof OutputOf)[] = [], + >( + fields: S & OnlyNominal, + options?: { + readonly generated?: G; + readonly immutable?: I; + readonly computed?: { [K in keyof A]: ComputedField> }; + readonly invariants?: readonly Invariant>[]; + }, + ): AbstractEntity => { + class Root { + static readonly entityName = name; + constructor() { + // A defect, not an `InvalidEntity`: a root is unreachable by + // construction — a variant's instances belong to the fresh base + // `extend` builds, not to this class — so reaching it is a bug in + // domain code. + // oxlint-disable-next-line unthrown/no-throw + throw new Error(`${name}: an abstract root has no instances — extend it and use make()`); + } + } + record(Root, fields as Fields, options as Record | undefined); + defineRootExtend(Root, buildEntity); + return Root as unknown as AbstractEntity; + }; diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index f604bed..9d85ede 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -3,7 +3,7 @@ import { Err, Ok, P, all, fromPromise, fromThrowable, type Result } from "unthro import type { z } from "zod"; import type { BuildEntity } from "./base.js"; -import { defineExtend, record } from "./base.js"; +import { createBase, defineExtend, record } from "./base.js"; import { computed, type ComputedField } from "./computed.js"; import { deepEqual } from "./equal.js"; import { InvalidEntity } from "./errors.js"; @@ -13,6 +13,7 @@ import { keysOf, renderIssue } from "./issues.js"; import { attachSchema } from "./schema.js"; import { shape, type OnlyNominal } from "./shape.js"; import type { + AbstractEntity, AsyncEntityFactory, AsyncGenerators, BaseInstance, @@ -442,6 +443,7 @@ export function Entity(tag: Tag) { Entity.computed = computed; Entity.invariant = invariant; Entity.union = union; +Entity.abstract = createBase(Entity as unknown as BuildEntity); Entity.InvalidEntity = InvalidEntity; // The issue helpers an adapter needs to turn an `InvalidEntity` into a // response body: `keysOf` normalises a Standard Schema path (bare key or @@ -473,6 +475,13 @@ type BaseInstanceSrc< A extends Fields, I extends readonly (keyof OutputOf)[], > = BaseInstance; +type AbstractEntitySrc< + Name extends string, + S extends Fields, + A extends Fields, + G extends readonly (keyof S)[], + I extends readonly (keyof OutputOf)[], +> = AbstractEntity; type EntityStaticSrc< Tag extends string, S extends Fields, @@ -533,4 +542,20 @@ export declare namespace Entity { G extends readonly (keyof S)[], I extends readonly (keyof OutputOf)[], > = EntityStaticSrc; + + /** What `Entity.abstract(name)(fields, options)` returns. */ + export type Abstract< + Name extends string, + S extends Fields, + A extends Fields, + G extends readonly (keyof S)[], + I extends readonly (keyof OutputOf)[], + > = AbstractEntitySrc; + + /** + * The instance type of an entity or a union — one line that cannot drift out + * of step with the members, where a hand-written + * `InstanceType | InstanceType` silently could. + */ + export type Instance = E["__instance"]; } diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index cefadcc..ddb5324 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -24,6 +24,11 @@ export { Entity } from "./entity.js"; // `EntityStatic<…>` by reference fixes both. Do not un-export it. export type { BaseInstance, ConstructionKey, EntityStatic, Sealed } from "./types.js"; +// Same story as `EntityStatic`: a consumer writing +// `abstract class X extends Entity.abstract("X")(…) {}` emits the *underlying* +// name into its declarations, not the `Entity.Abstract` path that aliases it. +export type { AbstractEntity } from "./types.js"; + // `Entity.union(...)` assigned to an exported `const` is the same story one // type further along: with no top-level name for what it returns, TypeScript // expands the union's members structurally and reaches zod's module-private diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 06bd949..d285d3d 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -245,6 +245,86 @@ type ConstructedInstance< readonly _tag: Tag; }; +/** + * The instance an `Entity.abstract(...)` root describes. + * + * `_tag` is widened to `string` rather than omitted, so shared behaviour can + * read it — and widening is what makes the root work at all. A `Tag` literal + * here collapses `extend`'s intersection: `"Account" & "Personal"` reduces to + * `never`, and TypeScript then rejects the whole base-constructor return type + * (TS2509). `string & "Personal"` reduces to `"Personal"`, which is exactly + * what the variant needs. + */ +export type RootInstance< + S extends Fields, + A extends Fields, + I extends readonly (keyof OutputOf)[], +> = BaseInstance & DeepReadonly> & { readonly _tag: string }; + +/** + * Whatever the receiver's own class body added, carried **unmapped**. + * + * Unmapped is load bearing. `Omit` and the key-remapped + * `{ [K in keyof R as …]: R[K] }` both turn a method into a function-typed + * property, and a variant implementing an abstract method then fails with + * `TS2425: … defines instance member property 'describe', but extended class + * 'Personal' defines it as instance member function`. Both spellings were + * measured. Nothing may be subtracted here — the root is tagless precisely so + * that nothing needs to be. + */ +export type BehaviourOf = This extends abstract new (...args: never[]) => infer R + ? R + : Record; + +/** + * What `Entity.abstract(name)(fields, options?)` returns. + * + * Deliberately not an entity: no `make`, no `factory`, no schema members. The + * absence of a tag is what lets `extend` intersect the receiver's instance type + * unmapped — see `RootInstance`. `name` labels the root in its defect message + * and never reaches an instance. + */ +export type AbstractEntity< + Name extends string, + S extends Fields, + A extends Fields, + G extends readonly (keyof S)[], + I extends readonly (keyof OutputOf)[], +> = { + new (d: Sealed>): RootInstance; + readonly entityName: Name; + /** + * A new entity carrying this root's fields plus more, under its own tag, and + * inheriting the class body of whatever it was called on. + * + * The `this` parameter is what picks up a behaviour-only intermediate root: + * `abstract class Auditable extends AccountBase { … }` then + * `Auditable.extend(...)` carries both bodies. + */ + extend( + this: This, + tag: Tag2, + ): < + S2 extends Fields, + A2 extends Fields = A, + const G2 extends readonly (keyof (S & S2))[] = G, + const I2 extends readonly (keyof OutputOf)[] = I extends readonly (keyof OutputOf< + S & S2, + A2 + >)[] + ? I + : [], + >( + fields: S2 & OnlyNominal, + options?: { + readonly generated?: G2; + readonly immutable?: I2; + readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; + readonly invariants?: readonly InvariantOf>[]; + }, + ) => EntityStatic>; +}; + /** * The full static surface `Entity(tag)(fields, options?)` returns. * @@ -263,8 +343,21 @@ export type EntityStatic< A extends Fields, G extends readonly (keyof S)[], I extends readonly (keyof OutputOf)[], + // What the abstract root's class body contributed, or nothing. Defaulted so + // every existing five-argument spelling keeps compiling. + // + // A sixth parameter lengthens every serialised instance type, which is the + // `TS7056` budget — the ceiling `index.ts` records two shipped build failures + // against, and the one 5.9.3 hits sooner than 7.0.2 does. It was measured, + // not assumed: `examples/billing-domain`'s two-compiler declaration pass + // emits clean on both TypeScript 7.0.2 and 5.9.3 with this arity, no + // `TS7056` from either. The headroom it spends is the `_zod` / `~standard` + // slots below naming `ConstructedInstance & B` instead of + // spelling that intersection out a second and third time — same type, fewer + // serialised characters. Widening this further means re-running that pass. + B = Record, > = { - new (d: Sealed>): ConstructedInstance; + new (d: Sealed>): ConstructedInstance & B; readonly entityName: Tag; readonly input: z.ZodObject; readonly output: z.ZodObject; @@ -285,17 +378,17 @@ export type EntityStatic< * property, unlike a method, takes no `this` parameter to infer the receiver * from — so it states the base shape and a caller narrows with `instanceof`. */ - readonly _zod: z.ZodType< - BaseInstance & DeepReadonly> & { readonly _tag: Tag } - >["_zod"]; - readonly "~standard": z.ZodType< - BaseInstance & DeepReadonly> & { readonly _tag: Tag } - >["~standard"]; + readonly _zod: z.ZodType & B>["_zod"]; + readonly "~standard": z.ZodType & B>["~standard"]; /** phantom carriers, so consumers can recover the shapes for annotations */ readonly __input: InputOf; readonly __output: OutputOf; readonly __createInput: CreateInputOf; readonly __patch: PatchOf; + /** 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; /** * A new entity with this one's fields plus more, under its own tag. From 9f8c73a16680544c0389652c5936603c122d733d Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 17:48:35 +0200 Subject: [PATCH 03/12] =?UTF-8?q?feat!:=20entities=20are=20final=20?= =?UTF-8?q?=E2=80=94=20extend=20moves=20to=20Entity.abstract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/entity/src/base.spec.ts | 46 ++++++++++ packages/entity/src/base.ts | 15 ---- packages/entity/src/entity.test-d.ts | 9 ++ packages/entity/src/entity.ts | 3 +- packages/entity/src/extend.spec.ts | 130 --------------------------- packages/entity/src/extend.test-d.ts | 33 ------- packages/entity/src/types.ts | 31 +------ 7 files changed, 58 insertions(+), 209 deletions(-) delete mode 100644 packages/entity/src/extend.spec.ts delete mode 100644 packages/entity/src/extend.test-d.ts diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index 0f60b31..f74c161 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -81,6 +81,52 @@ test("the root's options carry over", () => { expect(Personal.make({ id, label: "x".repeat(21), kind: "personal" }).isErr()).toBe(true); }); +test("the root's computed fields carry over and still re-derive", () => { + const updated = personal() + .update({ label: "grace" as z.infer }) + .getOrThrow(); + expect(updated.shout).toBe("GRACE"); +}); + +test("a variant's own schemas include both halves", () => { + expect(Object.keys(Personal.input.shape).toSorted()).toEqual(["id", "kind", "label"]); + expect(Object.keys(Personal.output.shape).toSorted()).toEqual(["id", "kind", "label", "shout"]); +}); + +test("a variant option overrides the root's for that key", () => { + class Loose extends AccountBase.extend("Loose")({ note: Label }, { immutable: [] }) { + override describe(): string { + return "loose"; + } + } + expect(Object.keys(Loose.updateInput.shape).toSorted()).toEqual(["id", "label", "note"]); +}); + +test("invariants are the exception: a variant adds to the root's, never replaces", () => { + const Score = z.number().int().brand("Score"); + class Stricter extends AccountBase.extend("Stricter")( + { score: Score }, + { invariants: [Entity.invariant((d) => d.score >= 18, "score must be at least 18")] }, + ) { + override describe(): string { + return "stricter"; + } + } + // the variant's own rule applies + expect(Stricter.make({ id, label: "Ada", score: 1 }).isErr()).toBe(true); + // and the root's still does — declaring invariants must not shed them + expect(Stricter.make({ id, label: "x".repeat(21), score: 30 }).isErr()).toBe(true); +}); + +test("a variant cannot relax the root by declaring an empty invariants list", () => { + class Loose extends AccountBase.extend("LooseInvariants")({ note: Label }, { invariants: [] }) { + override describe(): string { + return "loose"; + } + } + expect(Loose.make({ id, label: "x".repeat(21), note: "n" }).isErr()).toBe(true); +}); + test("an entity's own toJSON/equals/update shadow a root's", () => { abstract class Shadowing extends Entity.abstract("Shadowing")({ id: AccountId }) { override equals(): boolean { diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts index 24ca948..b4c92a8 100644 --- a/packages/entity/src/base.ts +++ b/packages/entity/src/base.ts @@ -75,21 +75,6 @@ const rebuild = ( ); }; -/** - * A *new* entity carrying this one's fields plus more, under its own tag. - * - * Not subclassing, which stays forbidden: the result is its own `Entity(...)` - * call, so it has a distinct tag, a distinct identity under `equals`, and its - * own schemas. - */ -export const defineExtend = (target: object, buildEntity: BuildEntity): void => { - Object.defineProperty(target, "extend", { - enumerable: false, - value: (nextTag: string) => (nextFields: Fields, nextOptions?: Record) => - rebuild(buildEntity, target, nextTag, nextFields, nextOptions), - }); -}; - /** * `extend` on a root: the same rebuild, plus the new base's prototype rewired * onto the receiver's. diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index 9e4b147..777be55 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -263,6 +263,15 @@ test("update() preserves the subclass type and its methods", () => { void tag; }); +test("an entity is final: extend lives on an abstract root", () => { + class Final extends Entity("Final")({ id: z.uuid().brand("FinalId") }) {} + // A concrete entity cannot carry behaviour into an extension at the type + // level — `"Final" & "Extended"` reduces to `never` (TS2509) — and a runtime + // that carried what the type did not would be worse than not carrying it. + // @ts-expect-error + Final.extend("Extended")({}); +}); + test("the helper types name each shape", () => { const Instant = z.iso.datetime().brand("Instant"); class Org extends Entity("Org")( diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 9d85ede..ea79a86 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -3,7 +3,7 @@ import { Err, Ok, P, all, fromPromise, fromThrowable, type Result } from "unthro import type { z } from "zod"; import type { BuildEntity } from "./base.js"; -import { createBase, defineExtend, record } from "./base.js"; +import { createBase, record } from "./base.js"; import { computed, type ComputedField } from "./computed.js"; import { deepEqual } from "./equal.js"; import { InvalidEntity } from "./errors.js"; @@ -426,7 +426,6 @@ export function Entity(tag: Tag) { attachSchema>(Base, input); record(Base, fields, options as Record | undefined); - defineExtend(Base, Entity as unknown as BuildEntity); return Base as unknown as EntityStatic; }; diff --git a/packages/entity/src/extend.spec.ts b/packages/entity/src/extend.spec.ts deleted file mode 100644 index 0c6ece0..0000000 --- a/packages/entity/src/extend.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { P } from "unthrown"; -import { expect, test } from "vitest"; -import { z } from "zod"; - -import { Entity } from "./index.js"; - -const PersonId = z.uuid().brand("PersonId"); -const Name = z.string().min(1).brand("Name"); -const Age = z.number().int().min(0).brand("Age"); -const Upper = z.string().min(1).brand("Upper"); - -class Person extends Entity("Person")( - { id: PersonId, name: Name }, - { - immutable: ["id"], - computed: { - shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer), - }, - invariants: [Entity.invariant((d) => d.name.length <= 20, "name must be at most 20 chars")], - }, -) {} - -class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { - get isAdult(): boolean { - return this.age >= 18; - } -} - -const id = "0199b1f4-1b1e-7000-8000-000000000000"; - -test("an extension carries the parent's fields plus its own", () => { - const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); - expect(p.name).toBe("ada"); - expect(p.age).toBe(36); -}); - -test("a class-body getter sees the extended fields", () => { - const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); - expect(p.isAdult).toBe(true); - expect(PersonWithAge.make({ id, name: "kid", age: 9 }).getOrThrow().isAdult).toBe(false); -}); - -test("the extension is its own entity, with its own tag", () => { - const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); - expect(p._tag).toBe("PersonWithAge"); - expect(PersonWithAge.entityName).toBe("PersonWithAge"); - expect(p).not.toBeInstanceOf(Person); -}); - -test("the parent's computed fields carry over and still re-derive", () => { - const p = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); - expect(p.shout).toBe("ADA"); - expect(p.update({ name: "grace" as z.infer }).getOrThrow().shout).toBe("GRACE"); -}); - -test("the parent's invariants carry over", () => { - const long = "x".repeat(21); - expect(PersonWithAge.make({ id, name: long, age: 36 }).isErr()).toBe(true); -}); - -test("the parent's immutable list carries over", () => { - expect(Object.keys(PersonWithAge.updateInput.shape).toSorted()).toEqual(["age", "name"]); -}); - -test("a child option overrides the parent's for that key", () => { - class Loose extends Person.extend("Loose")({ age: Age }, { immutable: [] }) {} - expect(Object.keys(Loose.updateInput.shape).toSorted()).toEqual(["age", "id", "name"]); -}); - -test("invariants are the exception: a child adds to the parent's, never replaces", () => { - class Stricter extends Person.extend("Stricter")( - { age: Age }, - { invariants: [Entity.invariant((d) => d.age >= 18, "must be an adult")] }, - ) {} - // the child's own rule applies - expect(Stricter.make({ id, name: "ada", age: 1 }).isErr()).toBe(true); - // and the parent's still does — declaring invariants must not shed them - expect(Stricter.make({ id, name: "x".repeat(21), age: 30 }).isErr()).toBe(true); -}); - -test("an extension cannot relax the parent by declaring an empty list", () => { - class Loose extends Person.extend("Loose")({ age: Age }, { invariants: [] }) {} - expect(Loose.make({ id, name: "x".repeat(21), age: 1 }).isErr()).toBe(true); -}); - -test("the extension's own schemas include both halves", () => { - expect(Object.keys(PersonWithAge.input.shape).toSorted()).toEqual(["age", "id", "name"]); - expect(Object.keys(PersonWithAge.output.shape).toSorted()).toEqual([ - "age", - "id", - "name", - "shout", - ]); -}); - -test("parent and extension are never equal, even with matching data", () => { - const parent = Person.make({ id, name: "ada" }).getOrThrow(); - const child = PersonWithAge.make({ id, name: "ada", age: 36 }).getOrThrow(); - expect(child.equals(parent)).toBe(false); - expect(parent.equals(child)).toBe(false); -}); - -test("the extension is still sealed and still refuses a bare subclass", () => { - class Sub extends PersonWithAge {} - const outcome = Sub.make({ id, name: "ada", age: 36 }).match({ - ok: () => "WRONGLY ACCEPTED", - errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), - defect: () => "defect", - }); - expect(outcome).toBe("defect"); -}); - -test("an extension can itself be extended", () => { - const Nick = z.string().min(1).brand("Nick"); - class Deeper extends PersonWithAge.extend("Deeper")({ nickname: Nick }) {} - const d = Deeper.make({ id, name: "ada", age: 36, nickname: "ace" }).getOrThrow(); - expect(d.nickname).toBe("ace"); - expect(d.age).toBe(36); - expect(d._tag).toBe("Deeper"); -}); - -test("extend carries declarations, not class-body members", () => { - // `extend` rebuilds from the field map and options — a getter written in the - // parent's class body is not part of either, so it does not come along. - // Re-declare it, or put shared behaviour in a plain function. - const Nick = z.string().min(1).brand("Nick"); - class Deeper extends PersonWithAge.extend("Deeper")({ nickname: Nick }) {} - const d = Deeper.make({ id, name: "ada", age: 36, nickname: "ace" }).getOrThrow(); - expect("isAdult" in d).toBe(false); -}); diff --git a/packages/entity/src/extend.test-d.ts b/packages/entity/src/extend.test-d.ts deleted file mode 100644 index 3077017..0000000 --- a/packages/entity/src/extend.test-d.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { test } from "vitest"; -import { z } from "zod"; - -import { Entity } from "./index.js"; - -const Id = z.uuid().brand("Id"); -const Name = z.string().min(1).brand("Name"); -const Age = z.number().int().brand("Age"); - -class Person extends Entity("Person")({ id: Id, name: Name }) {} - -test("extend enforces the same field rules as a fresh declaration", () => { - Person.extend("Ok")({ age: Age }); - - // @ts-expect-error an unbranded field is rejected, exactly as in Entity(...) - Person.extend("Unbranded")({ plain: z.string() }); - - // @ts-expect-error a reserved name is rejected, exactly as in Entity(...) - Person.extend("Reserved")({ update: Name }); -}); - -test("an extension's instance carries both halves of the shape", () => { - class WithAge extends Person.extend("WithAge")({ age: Age }) {} - const p = WithAge.make({}).getOrThrow(); - const name: z.infer = p.name; - const age: z.infer = p.age; - const tag: "WithAge" = p._tag; - void name; - void age; - void tag; - // @ts-expect-error the extension's data is still read-only - p.age = age; -}); diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index d285d3d..05afa89 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -390,35 +390,8 @@ export type EntityStatic< /** the instance type, read by `Entity.Instance` */ readonly __instance: ConstructedInstance & B; make(this: new (d: Sealed>) => T, state: unknown): Result; - /** - * A new entity with this one's fields plus more, under its own tag. - * - * The parent's options are inherited and merged per key, child winning, so - * an extension is never quietly laxer than what it extends. It is a fresh - * entity, not a subclass: distinct tag, distinct `equals` identity, its own - * schemas. - */ - extend( - tag: Tag2, - ): < - S2 extends Fields, - A2 extends Fields = A, - const G2 extends readonly (keyof (S & S2))[] = G, - const I2 extends readonly (keyof OutputOf)[] = I extends readonly (keyof OutputOf< - S & S2, - A2 - >)[] - ? I - : [], - >( - fields: S2 & OnlyNominal, - options?: { - readonly generated?: G2; - readonly immutable?: I2; - readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; - readonly invariants?: readonly InvariantOf>[]; - }, - ) => EntityStatic; + // No `extend`. An entity is final — extension lives on `AbstractEntity`, + // which is tagless and can therefore carry behaviour. See `BehaviourOf`. factory( this: new (d: Sealed>) => T, generators: Generators, From a8f9b8a205440b1196417d01287387a02e71d441 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 18:00:05 +0200 Subject: [PATCH 04/12] feat: return a class from Entity.union, so a union is a type with statics --- docs/typedoc.json | 1 + packages/entity/src/union.spec.ts | 44 ++++++++++++++ packages/entity/src/union.test-d.ts | 53 +++++++++++++++++ packages/entity/src/union.ts | 90 +++++++++++++++++++++++++---- 4 files changed, 178 insertions(+), 10 deletions(-) diff --git a/docs/typedoc.json b/docs/typedoc.json index e8641b5..2ac4310 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -34,6 +34,7 @@ "PatchOf", "RootInstance", "SealedSrc", + "SharedBase", "UpdateInputShapeOf" ] } diff --git a/packages/entity/src/union.spec.ts b/packages/entity/src/union.spec.ts index f8e6292..45f2749 100644 --- a/packages/entity/src/union.spec.ts +++ b/packages/entity/src/union.spec.ts @@ -152,3 +152,47 @@ test("a multi-value literal discriminant does not throw at construction", () => const U = Entity.union("plan", [Member2Free, Wide]); expect(U.make({ id: paidRow.id, plan: "tin" }).getOrThrow()).toBeInstanceOf(Wide); }); + +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 { + return `personal ${this.slug}`; + } +} +class Business extends AccountBase.extend("Business")({ kind: z.literal("business") }) { + override describe(): string { + return `business ${this.slug}`; + } +} + +class Account extends Entity.union("kind", [Personal, Business]) { + static ofLabel(label: string) { + return Account.make({ id: "0199b1f4-1b1e-7000-8000-000000000002", label, kind: "personal" }); + } +} + +test("a union declared as a class still dispatches to the member", () => { + expect(Account.ofLabel("Ada").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", () => { + 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 0789058..d58d4b6 100644 --- a/packages/entity/src/union.test-d.ts +++ b/packages/entity/src/union.test-d.ts @@ -37,3 +37,56 @@ test("a union needs at least two members", () => { // @ts-expect-error one member is not a union Entity.union("kind", [User]); }); + +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 { + return "personal"; + } +} +class Business extends AccountBase.extend("Business")({ kind: z.literal("business") }) { + override describe(): string { + return "business"; + } +} + +class Account extends Entity.union("kind", [Personal, Business]) {} +class Mixed extends Entity.union("kind", [User, Personal]) {} + +// `declare` is illegal inside a function body, so both annotations live here. +declare const anyAccount: Account; +declare const anyMixed: Mixed; + +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("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(); +}); diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index 246798f..0766507 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -12,6 +12,8 @@ export type UnionMember = { readonly entityName: string; readonly input: z.ZodObject; readonly output: z.ZodObject; + /** the abstract root the member was extended from, or the empty type */ + readonly __base: unknown; make(state: unknown): Result; } & z.core.$ZodType; @@ -23,11 +25,60 @@ 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. + * + * `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. + */ +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; readonly output: z.ZodType; + /** the exact member union, read by `Entity.Instance` */ + readonly __instance: InstanceOf; make(state: unknown): Result, InvalidEntity>; } & Pick>, "_zod" | "~standard">; @@ -177,16 +228,35 @@ 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 + // `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. const slots = instance as unknown as Record; - return { - discriminant, - members, - input, - output, - make, - _zod: slots["_zod"], - "~standard": slots["~standard"], - } as EntityUnion; + 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; } From 921a3bd8f8f6ab450f86cdbed45b0fd789e2867b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 18:13:25 +0200 Subject: [PATCH 05/12] test: model the billing documents as a root and a union class --- examples/billing-domain/src/emit-guards.ts | 11 +++- examples/billing-domain/src/index.spec.ts | 37 +++++++++++++ examples/billing-domain/src/index.ts | 62 ++++++++++++++-------- 3 files changed, 87 insertions(+), 23 deletions(-) diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index 6ef249d..f0778f7 100644 --- a/examples/billing-domain/src/emit-guards.ts +++ b/examples/billing-domain/src/emit-guards.ts @@ -34,7 +34,7 @@ import type { z } from "zod"; // `Organization` is imported as a value: the sealed-construction assertion // below needs the runtime binding to write `new Organization(...)` at all. import { Organization } from "./index.js"; -import type { CreditNote, DisplayLabel, Invoice, Slug } from "./index.js"; +import type { BillingDocument, CreditNote, DisplayLabel, Invoice, Money, Slug } from "./index.js"; /* ── Construction stays sealed from outside the package ───────────────── */ @@ -72,6 +72,15 @@ export type Static = Entity.Static< [] >; export type Members = Entity.Union<"kind", [typeof Invoice, typeof CreditNote]>; +export type AnyDocument = Entity.Instance; +export type OneInvoice = Entity.Instance; +export type Root = Entity.Abstract< + "BillingDocument", + { total: typeof Money }, + Record, + [], + [] +>; /** The error is reachable as both a value and a type. */ export const isInvalid = (error: unknown): error is Entity.InvalidEntity => diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index ab6ad38..15e4361 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -95,6 +95,43 @@ test("a malformed row comes back as an error, not an exception", () => { expect(Organization.make({ slug: "", name: "" }).isErr()).toBe(true); }); +/* ── The root carries the fields and the behaviour both variants share ── */ + +test("both documents carry the root's behaviour", () => { + const drafted = invoice(); + expect(drafted.counterpartySlug).toBe("acme"); + expect(drafted.signedAmount()).toBe(12_00); +}); + +test("each variant signs the shared amount its own way", () => { + const note = createCreditNote({ + issuedTo: org(), + against: InvoiceId.parse("33333333-3333-4333-8333-333333333333"), + total: money(500, "EUR"), + }).getOrThrow(); + + expect(note.signedAmount()).toBe(-500); + expect(note.counterpartySlug).toBe("acme"); +}); + +test("the root's invariant guards a variant that declares none of its own", () => { + // `CreditNote` no longer spells out "total must not be negative" — the root + // does. An extension can add rules; it cannot shed them. + const rejected = createCreditNote({ + issuedTo: org(), + against: InvoiceId.parse("33333333-3333-4333-8333-333333333333"), + total: money(-1, "EUR"), + }); + + expect(rejected.isErr()).toBe(true); +}); + +test("a variant is an instance of the root the union shares", () => { + // `BillingDocumentBase` is not exported — it is a declaration detail. The + // observable consequence is that `make` still yields the concrete variant. + expect(BillingDocument.make(invoice().toJSON()).getOrThrow()).toBeInstanceOf(Invoice); +}); + /* ── The union dispatches on a DECLARED field, never on `_tag` ────────── These four are the tests whose absence let a broken union ship: the first version of this file discriminated on "_tag", which is non-enumerable and diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index 2e47230..ba68c39 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -1,8 +1,9 @@ /** * A small billing domain, modelled with `@btravstack/entity`. * - * Read it top to bottom: the field vocabulary first, then the two entities, - * then the factories binding them to their effect sources. Every shape here is + * Read it top to bottom: the field vocabulary first, then the entities — one + * standalone, and a root with two variants under a union — then the factories + * binding them to their effect sources. Every shape here is * one a billing model actually needs — including the two that once broke * declaration emit for consumers: a branded `Money` object, and a dunning * vocabulary wide enough to matter. See `emit-guards.ts`. @@ -118,26 +119,43 @@ export class Organization extends Entity("Organization")( } /** + * What every billing document shares. A root rather than a third entity: it is + * tagless, has no `make`, and exists to hold the fields and the behaviour the + * variants have in common. `Entity.abstract` is the only extensible declaration + * — an entity itself is final. + * * `issuedTo` is another entity used directly as a field: the class is itself a * zod schema, so it parses back to a real `Organization`, behaviour and all. */ -export class Invoice extends Entity("Invoice")( +abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( + { issuedTo: Organization, total: Money, issuedAt: Instant }, + { + generated: ["issuedAt"], + immutable: ["issuedAt", "issuedTo"], + invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], + }, +) { + /** The rule both variants owe the ledger, written once. */ + abstract signedAmount(): number; + + get counterpartySlug(): string { + return this.issuedTo.slug; + } +} + +export class Invoice extends BillingDocumentBase.extend("Invoice")( { id: InvoiceId, kind: z.literal("INVOICE"), - issuedTo: Organization, lines: z.array(LineItem), - total: Money, status: InvoiceStatus, dunningReasons: z.array(DunningReason), level: Level, - issuedAt: Instant, }, { generated: ["id", "issuedAt", "kind"], immutable: ["id", "issuedAt", "issuedTo", "kind"], invariants: [ - Entity.invariant((d) => d.total.amount >= 0, "total must not be negative"), Entity.invariant( (d) => d.status !== "VOID" || d.dunningReasons.length === 0, "a void invoice cannot be in dunning", @@ -145,6 +163,10 @@ export class Invoice extends Entity("Invoice")( ], }, ) { + override signedAmount(): number { + return this.total.amount; + } + get isCollectable(): boolean { return this.status === "ISSUED" || this.status === "DRAFT"; } @@ -152,25 +174,21 @@ export class Invoice extends Entity("Invoice")( /** * A credit note is an invoice's sibling, not its subtype: same counterparty and - * money, opposite direction, its own identity. Modelling it as a second entity - * sharing the `kind` discriminant is what lets both travel down one channel and - * come back as the right class. + * money, opposite direction, its own identity. Modelling it as a second variant + * of the root, sharing the `kind` discriminant, is what lets both travel down + * one channel and come back as the right class. */ -export class CreditNote extends Entity("CreditNote")( - { - id: CreditNoteId, - kind: z.literal("CREDIT_NOTE"), - issuedTo: Organization, - against: InvoiceId, - total: Money, - issuedAt: Instant, - }, +export class CreditNote extends BillingDocumentBase.extend("CreditNote")( + { id: CreditNoteId, kind: z.literal("CREDIT_NOTE"), against: InvoiceId }, { generated: ["id", "issuedAt", "kind"], immutable: ["id", "issuedAt", "issuedTo", "against", "kind"], - invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], }, -) {} +) { + override signedAmount(): number { + return -this.total.amount; + } +} /** * Dispatches on `kind` — a **declared domain field**, never the entity's @@ -182,7 +200,7 @@ export class CreditNote extends Entity("CreditNote")( * The two mechanisms are not redundant. This field discriminates **data** on * the way in; `P.tag(...)` matches an **instance** you already hold. */ -export const BillingDocument = Entity.union("kind", [Invoice, CreditNote] as const); +export class BillingDocument extends Entity.union("kind", [Invoice, CreditNote]) {} /* ── Binding the effect sources ──────────────────────────────────────── The package reads no clock and generates no id. A factory is where those From 8bd0583d090bd11aa7c318fc443cf3ae359855aa Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 18:40:43 +0200 Subject: [PATCH 06/12] test: state the extend merge rule and tighten the root's coverage --- examples/billing-domain/src/index.spec.ts | 18 ++++++++---------- examples/billing-domain/src/index.ts | 3 +++ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index 15e4361..6312a9c 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -97,7 +97,7 @@ test("a malformed row comes back as an error, not an exception", () => { /* ── The root carries the fields and the behaviour both variants share ── */ -test("both documents carry the root's behaviour", () => { +test("an invoice carries the root's behaviour", () => { const drafted = invoice(); expect(drafted.counterpartySlug).toBe("acme"); expect(drafted.signedAmount()).toBe(12_00); @@ -114,22 +114,20 @@ test("each variant signs the shared amount its own way", () => { expect(note.counterpartySlug).toBe("acme"); }); -test("the root's invariant guards a variant that declares none of its own", () => { +test("the root's invariant guards a variant that declares none of its own", async () => { // `CreditNote` no longer spells out "total must not be negative" — the root // does. An extension can add rules; it cannot shed them. - const rejected = createCreditNote({ + const message = await createCreditNote({ issuedTo: org(), against: InvoiceId.parse("33333333-3333-4333-8333-333333333333"), total: money(-1, "EUR"), + }).match({ + ok: () => "ok", + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues[0]?.message ?? ""), + defect: () => "defect", }); - expect(rejected.isErr()).toBe(true); -}); - -test("a variant is an instance of the root the union shares", () => { - // `BillingDocumentBase` is not exported — it is a declaration detail. The - // observable consequence is that `make` still yields the concrete variant. - expect(BillingDocument.make(invoice().toJSON()).getOrThrow()).toBeInstanceOf(Invoice); + expect(message).toBe("total must not be negative"); }); /* ── The union dispatches on a DECLARED field, never on `_tag` ────────── diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index ba68c39..03dbb0b 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -153,6 +153,9 @@ export class Invoice extends BillingDocumentBase.extend("Invoice")( level: Level, }, { + // `generated` and `immutable` **replace** the root's list per key rather + // than adding to it, so a variant re-states every key it needs — + // `issuedAt` and `issuedTo` here. Only `invariants` concatenate. generated: ["id", "issuedAt", "kind"], immutable: ["id", "issuedAt", "issuedTo", "kind"], invariants: [ From 5bfd6665c62351a322e4be8506bf0cf8d80ed4cd Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 18:58:11 +0200 Subject: [PATCH 07/12] test: guard that a root's abstract member binds every variant --- packages/entity/src/base.test-d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/entity/src/base.test-d.ts b/packages/entity/src/base.test-d.ts index b3285c8..8bb8a81 100644 --- a/packages/entity/src/base.test-d.ts +++ b/packages/entity/src/base.test-d.ts @@ -80,6 +80,12 @@ test("a root is not an entity", () => { void AccountBase.input; }); +test("a root's abstract member is an obligation on every variant", () => { + // @ts-expect-error TS2515: `Forgot` does not implement inherited abstract member `describe` + class Forgot extends AccountBase.extend("Forgot")({ note: Label }) {} + void Forgot; +}); + test("a root enforces the same field rules as a fresh declaration", () => { AccountBase.extend("Ok")({ ok: Label }); // @ts-expect-error an unbranded field is rejected, exactly as in Entity(...) From 3ba6b63ea93419d9d3f00191a6b9c296f121b3e3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 18:58:18 +0200 Subject: [PATCH 08/12] docs: cover abstract roots, union classes, and the extend migration --- .../abstract-roots-and-union-classes.md | 37 +++++ CLAUDE.md | 57 ++++++-- README.md | 35 ++++- docs/.vitepress/config.ts | 1 + docs/api/index.md | 12 +- docs/examples/billing-domain.md | 75 +++++++++- docs/examples/index.md | 5 +- docs/explanation/sealed-construction.md | 12 +- docs/explanation/unions-and-roots.md | 127 ++++++++++++++++ docs/how-to/evolve-an-entity.md | 51 +++++++ docs/how-to/http-contract.md | 2 +- docs/how-to/model-an-aggregate.md | 89 ++++++++--- docs/reference/declaration.md | 138 +++++++++++++++--- docs/reference/types.md | 44 ++++-- packages/entity/README.md | 27 +++- 15 files changed, 624 insertions(+), 88 deletions(-) create mode 100644 .changeset/abstract-roots-and-union-classes.md create mode 100644 docs/explanation/unions-and-roots.md diff --git a/.changeset/abstract-roots-and-union-classes.md b/.changeset/abstract-roots-and-union-classes.md new file mode 100644 index 0000000..1edc985 --- /dev/null +++ b/.changeset/abstract-roots-and-union-classes.md @@ -0,0 +1,37 @@ +--- +"@btravstack/entity": minor +--- + +Add `Entity.abstract(name)(fields, options?)`, a tagless root that carries shared +fields **and shared behaviour** into every entity extended from it, and make +`Entity.union(...)` return a class so a union can be declared with +`class X extends Entity.union(...) {}` and used as a type. `Entity.Instance` +recovers an entity's or a union's instance type. + +A root is a real supertype: `variant instanceof Root` is true, an `abstract` +member on the root is enforced on every variant (`TS2515`), and a +behaviour-only intermediate `abstract class` between the two is picked up. A +union's class body is for **statics** — it has no instances, and as a type it is +the root its members share; `Entity.Instance` is the exact member +union. + +**Breaking:** `extend` is no longer on an entity — an entity is final. Wrap the +shared fields in an abstract root and declare both entities as variants of it: + +```ts +// before +class Person extends Entity("Person")({ id: Id, name: Name }) {} +class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) {} + +// after +abstract class PersonBase extends Entity.abstract("Person")({ + id: Id, + name: Name, +}) {} +class Person extends PersonBase.extend("Person")({}) {} +class PersonWithAge extends PersonBase.extend("PersonWithAge")({ age: Age }) {} +``` + +A root is where behaviour shared by every variant lives, which is what the old +`extend` could not carry: it rebuilt from the declaration alone, so class-body +members had to be written again per extension. diff --git a/CLAUDE.md b/CLAUDE.md index 3a7fa7b..aa32f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ cannot run against it. Measured — the reason is inline in ## Architecture -Ten source modules under `packages/entity/src`, split by what they own: +Eleven source modules under `packages/entity/src`, split by what they own: - **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from @@ -80,11 +80,24 @@ Ten source modules under `packages/entity/src`, split by what they own: `JSON.stringify`, or spread. `toJSON()` is the **only** public projection — it, `equals` and `update` all route through a module-private `project`, so there is no second public spelling of the same data. It also carries the - whole public surface: `Entity.computed` / `Entity.union` / - `Entity.InvalidEntity` as expando properties, and every public type in a + whole public surface: `Entity.computed` / `Entity.invariant` / + `Entity.abstract` / `Entity.union` / `Entity.InvalidEntity` as expando + properties, and every public type in a merged `declare namespace Entity`. Namespace members alias imported types through `*Src` names deliberately — see the comment there before renaming one. +- **`base.ts`** — `Entity.abstract(name)(fields, options?)` and the `extend` + that lives on what it returns. A root is tagless, has no `make` and none of + the four schema members; it exists to be extended and to hold the behaviour + every variant shares. `extend` rebuilds a fresh entity from the declaration + record (a `WeakMap` keyed by the class, walked up the _static_ chain so a + user's own intermediate subclass still finds it), then rewires the new + prototype onto the receiver's — which is what makes `variant instanceof Root` + true, picks up a behaviour-only intermediate root, and leaves the entity's own + `toJSON`/`equals`/`update` shadowing anything a root declares under those + names. Options merge per key, child winning, except `invariants`, which + concatenate. Built against a loosened `BuildEntity` passed in from + `entity.ts`, so this module imports no builder and there is no cycle. - **`equal.ts`** — `deepEqual`, the primitive behind `equals`. Not `JSON.stringify`: that **threw** on a `bigint` field, compared `Set`/`Map`/ typed-array fields with different contents as **equal**, and reported a @@ -99,7 +112,8 @@ Ten source modules under `packages/entity/src`, split by what they own: passes one `WeakSet` across every field, so a subtree two fields share is walked once. - **`types.ts`** — the whole type-level derivation (`OutputOf`, - `CreateInputOf`, `PatchOf`, `UpdateInputShapeOf`, `EntityStatic`), plus + `CreateInputOf`, `PatchOf`, `UpdateInputShapeOf`, `EntityStatic`, + `AbstractEntity`/`RootInstance`/`BehaviourOf`), plus `Sealed`, the module-private `unique symbol` that makes `new X(...)` a compile error. Written independently of the builder's body-local values so `EntityStatic` can serve as the builder's explicit return annotation. @@ -110,7 +124,13 @@ Ten source modules under `packages/entity/src`, split by 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. + 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. - **`shape.ts`** — `OnlyNominal`, the type-level check rejecting unbranded fields, and `shape()`, which builds the validated field map. Both are internal; neither is exported from `index.ts`. @@ -141,8 +161,15 @@ 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 (TS2411, TS2526, TS4020, TS4111) or a - measured library behaviour. Verify before "simplifying" them away — the + cite a specific TS diagnostic code (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 catalog in `pnpm-workspace.yaml` pins `typescript` and `@orpc/zod` to the exact versions those measurements were taken against, with the reason inline. - **Type-level behaviour lives in `*.test-d.ts`**, checked by @@ -154,17 +181,21 @@ design — `contract.spec.ts` pins that both ways. library can be "done". Resist convenience aliases. - **`index.ts` exports `Entity`, and nothing else you write against.** A bare `computed` or `union` is too generic to take from a consumer's import scope, - so everything hangs off the builder. The sole exception is `BaseInstance` / - `ConstructionKey` / `Sealed`, exported at the top level as well: a downstream + so everything hangs off the builder. The sole exception is the seven + declaration-emit names — `AbstractEntity`, `BaseInstance`, `ConstructionKey`, + `EntityStatic`, `EntityUnion`, `Sealed`, `UnionMember` — exported at the top + level as well: a downstream library compiling with `declaration: true` emits the _underlying_ name, not the namespace path aliasing it, so hiding them fails the consumer pass with `TS4020`. That is measured, not assumed — `examples/billing-domain/src/emit-guards.ts` names every namespace member for exactly this reason, and an **unused** `@ts-expect-error` there is a failure signal, not noise. -- **Entities are not subclassable.** One `extends` is the declaration form; - `construct` defects on anything deeper. Behaviour goes in the entity's own - class body. This is runtime-only — TypeScript has no `final`, and - `private`/`protected` constructors were measured to break the declaration +- **Entities are final.** One `extends` is the declaration form; `construct` + defects on anything deeper, and `EntityStatic` carries no `extend`. Behaviour + goes in the entity's own class body. Extension lives on `Entity.abstract`, + which is tagless and can therefore carry a class body into every variant — + `base.ts` above. The ban on a deeper `extends` is runtime-only: TypeScript has no `final`, + and `private`/`protected` constructors were measured to break the declaration form (TS2675) and the statics (TS2684) respectively. - **No I/O.** The package reads no clock and generates no id. `create` lives on a factory (`Entity.factory(generators)` / `factoryAsync`) — a function you diff --git a/README.md b/README.md index ac65e2e..27f9913 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,36 @@ Organization.make({ ...row, name: "" }).match({ | `computed` | fields derived from the declared ones, re-derived on every construction | | `invariants` | rules built with `Entity.invariant`; any failing rule rejects | -Also `Entity.union(discriminant, members)` for a union that is itself -entity-like, and `SomeEntity.extend(tag)(fields)` to build a new entity from an -existing one. +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: + +```ts +abstract class AccountBase extends Entity.abstract("Account")({ + id: AccountId, + label: DisplayName, +}) { + abstract describe(): string; // every variant owes this — the compiler checks +} + +class Personal extends AccountBase.extend("Personal")({ + kind: z.literal("personal"), +}) { + override describe(): string { + return `personal ${this.label}`; + } +} + +// `Business` is declared the same way, on the same root +class Account extends Entity.union("kind", [Personal, Business]) {} + +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. +([Why](https://btravstack.github.io/entity/explanation/unions-and-roots).) ## Documentation @@ -154,7 +181,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 entities are not subclassable. +- **[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. ## Development diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 9ce69da..7ea3e3f 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -46,6 +46,7 @@ const GUIDE_SIDEBAR = [ { text: "Immutability", link: "/explanation/immutability" }, { text: "Why computed re-derives", link: "/explanation/computed-fields" }, { text: "Tags and identity", link: "/explanation/tags-and-identity" }, + { text: "Unions and roots", link: "/explanation/unions-and-roots" }, { text: "Errors are values", link: "/explanation/errors-are-values" }, { text: "Peer dependencies", link: "/explanation/peer-dependencies" }, ], diff --git a/docs/api/index.md b/docs/api/index.md index 7affa95..dc4c95a 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -23,10 +23,10 @@ _why_ the surface is shaped this way, read the import { Entity } from "@btravstack/entity"; ``` -`Entity.computed`, `Entity.invariant`, `Entity.union`, `Entity.InvalidEntity`, -`Entity.keysOf` and `Entity.renderIssue` hang off it as values, and every -public type lives in a merged `declare namespace Entity`. A bare `computed` or -`union` would be too generic to take from a consumer's import scope, so nothing -else is exported — with one measured exception, the -[six declaration-emit type names](/reference/types#the-declaration-emit-names) +`Entity.computed`, `Entity.invariant`, `Entity.abstract`, `Entity.union`, +`Entity.InvalidEntity`, `Entity.keysOf` and `Entity.renderIssue` hang off it as +values, and every public type lives in a merged `declare namespace Entity`. A +bare `computed` or `union` would be too generic to take from a consumer's import +scope, so nothing else is exported — with one measured exception, the +[seven declaration-emit type names](/reference/types#the-declaration-emit-names) a consumer's own `.d.ts` has to be able to write. diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 2e432d0..28906e7 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -1,12 +1,13 @@ --- title: Billing domain example -description: Declaring entities — branded fields, generated/immutable/computed, invariants, nesting, unions and factories — in a runnable package. +description: Declaring entities — branded fields, generated/immutable/computed, invariants, nesting, abstract roots, unions and factories — in a runnable package. --- # Billing domain [`examples/billing-domain`](https://github.com/btravstack/entity/tree/main/examples/billing-domain) -— the modelling half: two entities and the vocabulary they are built from. +— the modelling half: one standalone entity, a root with two variants under a +union, and the vocabulary they are all built from. ```sh pnpm --filter @btravstack/entity-example-billing-domain test @@ -75,10 +76,64 @@ label followed. Behaviour lives in the class body. This is a real class, not a record with functions bolted beside it. +## What both documents share is a root + +An invoice and a credit note are siblings, not subtypes of one another: same +counterparty and money, opposite direction, their own identities. What they +share goes on an `Entity.abstract` root — tagless, with no `make` of its own, +extended rather than instantiated: + +```ts +abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( + { issuedTo: Organization, total: Money, issuedAt: Instant }, + { + generated: ["issuedAt"], + immutable: ["issuedAt", "issuedTo"], + invariants: [ + Entity.invariant( + (d) => d.total.amount >= 0, + "total must not be negative", + ), + ], + }, +) { + /** the rule both variants owe the ledger */ + abstract signedAmount(): number; + + get counterpartySlug(): string { + return this.issuedTo.slug; + } +} + +export class Invoice extends BillingDocumentBase.extend("Invoice")( + { id: InvoiceId, kind: z.literal("INVOICE") /* … */ }, + { + generated: ["id", "issuedAt", "kind"], + immutable: ["id", "issuedAt", "issuedTo", "kind"], + }, +) { + override signedAmount(): number { + return this.total.amount; + } +} +``` + +`abstract signedAmount()` is the point of the root: a variant that forgets it +does not compile (`TS2515`). `counterpartySlug` is the other half — behaviour +written once and inherited, which is what a rebuilt-from-the-declaration +extension could not carry. An entity itself is final; `extend` lives only here. + +Note what the variants re-state. `generated` and `immutable` **replace** the +root's list for that key rather than adding to it, so `Invoice` names `issuedAt` +and `issuedTo` again alongside its own. Only `invariants` concatenate — the +root's "total must not be negative" applies to both variants whether or not they +declare rules of their own. + ## Nesting, and the factory -`Invoice.issuedTo` is an `Organization` used directly as a field. The class is -itself a zod schema, so it parses back to a real instance: +`issuedTo` is declared on the root, so every variant has one — and it is an +`Organization`, an entity used directly as a field. The class is itself a zod +schema, so it parses back to a real instance: ```ts const rehydrated = Invoice.make(invoice.toJSON()).getOrThrow(); @@ -116,12 +171,20 @@ self-alias still compiles and simply degenerates. ## The union discriminates data, not instances ```ts -export const BillingDocument = Entity.union("kind", [ +export class BillingDocument extends Entity.union("kind", [ Invoice, CreditNote, -] as const); +]) {} ``` +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. + `kind` is a **declared domain field** — `z.literal("INVOICE")` on one member and `z.literal("CREDIT_NOTE")` on the other, both `generated` so no caller can supply the wrong one. diff --git a/docs/examples/index.md b/docs/examples/index.md index 2b053d3..081fe17 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -23,8 +23,9 @@ pnpm test ## [Billing domain](/examples/billing-domain) Declaring the entities: branded fields, `generated` / `immutable` / `computed`, -invariants as values, one entity nested inside another, a discriminated union, -and factories binding the id and clock the package refuses to read for itself. +invariants as values, one entity nested inside another, an abstract root with +two variants gathered under a discriminated union, and factories binding the id +and clock the package refuses to read for itself. ## [HTTP contract](/examples/billing-api) diff --git a/docs/explanation/sealed-construction.md b/docs/explanation/sealed-construction.md index 5f49d46..35c24cd 100644 --- a/docs/explanation/sealed-construction.md +++ b/docs/explanation/sealed-construction.md @@ -1,6 +1,6 @@ --- title: Sealed construction -description: Why new SomeEntity(…) does not compile, the two alternatives that were measured and rejected, and why entities are not subclassable. +description: Why new SomeEntity(…) does not compile, the two alternatives that were measured and rejected, and why an entity is final. --- # Sealed construction @@ -35,14 +35,16 @@ with `TS4020: 'extends' clause of exported class has or is using private name`. A fixture in CI compiles a consumer with declaration emit against the built types, so that cannot regress. -## Entities are not subclassable +## An entity is final -`class Sub extends Organization {}` fails at construction with a `Defect`. +`class Sub extends Organization {}` fails at construction with a `Defect`, and +`Organization.extend` does not exist. A bare subclass is an alias you cannot tell apart from what it aliases: same tag, same schemas, indistinguishable under `equals`. -[`extend`](/reference/declaration#someentity-extend-tag-fields-options) exists -for the legitimate case and produces a genuine entity with its own identity. +[`Entity.abstract`](/reference/declaration#entity-abstract-name-fields-options) +exists for the legitimate case: extension lives on a root, which is tagless and +therefore has an identity to give away rather than one to duplicate. The prohibition is runtime-only. TypeScript has no `final`, and the constructor accessibility modifiers that would express it break the declaration form or the diff --git a/docs/explanation/unions-and-roots.md b/docs/explanation/unions-and-roots.md new file mode 100644 index 0000000..2529ab9 --- /dev/null +++ b/docs/explanation/unions-and-roots.md @@ -0,0 +1,127 @@ +--- +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. +--- + +# Unions and roots + +Two of the declarations in this package are classes you extend rather than +values you hold: + +```ts +abstract class AccountBase extends Entity.abstract("Account")({ + id: AccountId, + label: Label, +}) {} + +class Account extends Entity.union("kind", [Personal, Business]) {} +``` + +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. + +## Why a union's type is its members' root, not its members + +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 declared from different roots, or from no root +at all, share nothing, and the union claims the empty type rather than a +supertype that does not exist. + +The exact union is not lost, only spelled elsewhere: + +```ts +class Account extends Entity.union("kind", [Personal, Business]) {} + +declare const account: Account; // AccountBase — the shared root +type AnyAccount = Entity.Instance; // Personal | Business +``` + +`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. + +## 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() +``` + +## Why the root carries no tag + +An entity's `_tag` is a literal type. A root's is `string`, and the widening is +what makes the root work at all rather than a convenience. + +`extend` builds a variant by intersecting the root's instance type with the new +entity's. Two literal tags do not intersect: `"Account" & "Personal"` reduces to +`never`, which poisons the whole instance type and puts the base-constructor +return type back in front of TS2509. `string & "Personal"` reduces to +`"Personal"` — exactly what the variant needs, and what lets shared behaviour in +the root's body still read `this._tag`. + +The tag is also the only member that would have needed subtracting, and not +subtracting it is what keeps the variant's methods methods. The root's class +body is carried into the intersection **unmapped**. Both spellings that would +map it — `Omit` and a key-remapped `{ [K in keyof R as …]: R[K] }` — turn +a method into a function-typed property, and a variant implementing an abstract +method then fails with + +``` +TS2425: … defines instance member property 'describe', but extended class +'Personal' defines it as instance member function +``` + +## `abstract` survives, deliberately + +Because the intersection is unmapped and its behaviour half is the root's real +class type, TypeScript propagates abstractness through it. An `abstract` member +on a root is a compiler-enforced obligation on every variant: + +```ts +abstract class AccountBase extends Entity.abstract("Account")({ + id: AccountId, + label: Label, +}) { + abstract describe(): string; +} + +// @ts-expect-error TS2515: does not implement inherited abstract member describe +class Forgot extends AccountBase.extend("Forgot")({ + kind: z.literal("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. + +## What a root cannot take over + +`toJSON`, `equals` and `update` are declared on the entity's own prototype, and +`extend` chains the root's prototype **below** it. So a root can call all three, +and can never override them: a member declared under one of those names on a +root compiles, and is silently never called. The chaining is what buys the rest +— `variant instanceof Root` is true, and a behaviour-only intermediate root +between the two is picked up with no bookkeeping. + +Behaviour that must differ per variant belongs in the variant's own body, which +is where an `abstract` member on the root already puts it. diff --git a/docs/how-to/evolve-an-entity.md b/docs/how-to/evolve-an-entity.md index e895978..0c0bd94 100644 --- a/docs/how-to/evolve-an-entity.md +++ b/docs/how-to/evolve-an-entity.md @@ -94,6 +94,57 @@ Retiring is also what makes the **declaration-first** habit safe: a field the model no longer names cannot be read, so any code still using it fails to compile at the moment of the change, not in production. +## Split one entity into variants + +When one entity has grown two shapes, move the shared half onto an +[abstract root](/reference/declaration#entity-abstract-name-fields-options) and +declare each shape as a variant of it. An entity is final, so this is the +extension point: + +```ts +// before +class Document extends Entity("Document")({ id: DocId, total: Cents }) {} + +// after +abstract class DocumentBase extends Entity.abstract("Document")({ + id: DocId, + total: Cents, +}) { + /** shared behaviour lives on the root, and each variant owes this one */ + abstract signedAmount(): number; +} + +class Invoice extends DocumentBase.extend("Invoice")({ + kind: z.literal("INVOICE"), +}) { + override signedAmount(): number { + return this.total; + } +} + +class CreditNote extends DocumentBase.extend("CreditNote")({ + kind: z.literal("CREDIT_NOTE"), +}) { + override signedAmount(): number { + return -this.total; + } +} +``` + +A variant's `input` is the root's fields plus its own, so the shared half of a +stored row still validates unchanged. What is new is the **discriminant**, which +is a required field like any other — backfill it, or default it on the variant +that owns the old rows. See [Add a required field](#add-a-required-field). + +The old rows are otherwise untouched: `_tag` moves from `"Document"` to +`"Invoice"`, but it is non-enumerable and never stored, so nothing on disk knows +the difference. Anything reading `entityName`, or matching on `P.tag`, does. + +Options declared on the root are inherited, and `invariants` **concatenate** — +a variant can add rules but never shed the root's. Every other option replaces +the root's list for that key, so a variant declaring `generated` or `immutable` +re-states every key it needs. + ## Computed fields heal themselves A computed field needs no migration story at all: `make` validates the diff --git a/docs/how-to/http-contract.md b/docs/how-to/http-contract.md index 4121100..74db232 100644 --- a/docs/how-to/http-contract.md +++ b/docs/how-to/http-contract.md @@ -117,7 +117,7 @@ to attach the message to a form field or to the form. polymorphic endpoint keeps its contract: ```ts -const Member = Entity.union("kind", [User, ServiceAccount]); +class Member extends Entity.union("kind", [User, ServiceAccount]) {} 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 90f7e49..ae00f60 100644 --- a/docs/how-to/model-an-aggregate.md +++ b/docs/how-to/model-an-aggregate.md @@ -94,26 +94,58 @@ Order.make(json).getOrThrow().customer instanceof Customer; // true ## Model a union of entities -When a field can be one of several entities, declare the discriminant as an -ordinary domain field and use `Entity.union`: +When a field can be one of several entities, put what they share on a root, +give each variant its own discriminant field, and gather them with +`Entity.union`: ```ts -class User extends Entity("User")({ +abstract class MemberBase extends Entity.abstract("Member")({ id: MemberId }) { + /** every variant owes the caller a display label */ + abstract label(): string; +} + +class User extends MemberBase.extend("User")({ kind: z.literal("user"), - id: UserId, email: Email, -}) {} -class ServiceAccount extends Entity("ServiceAccount")({ +}) { + override label(): string { + return this.email; + } +} + +class ServiceAccount extends MemberBase.extend("ServiceAccount")({ kind: z.literal("service_account"), - id: SvcId, - label: Label, -}) {} + name: Name, +}) { + override label(): string { + return this.name; + } +} -const Member = Entity.union("kind", [User, ServiceAccount]); +class Member extends Entity.union("kind", [User, ServiceAccount]) {} Member.make(row).getOrThrow(); // User | ServiceAccount — the real class ``` +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 + +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: + +```ts +class Member extends Entity.union("kind", [User, ServiceAccount]) { + static fromRow(row: unknown) { + return Member.make(row); + } +} +``` + 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 @@ -135,25 +167,46 @@ A union is a schema too, so it nests: 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: + +```ts +type AnyMember = Entity.Instance; // User | ServiceAccount +``` + +Either annotation is usable: the root gives you `label()` and `id`, the member +union gives you each variant's own fields. +([Why the two differ](/explanation/unions-and-roots).) + ## Match exhaustively on what comes back ```ts -const describe = (m: User | ServiceAccount) => +const describe = (m: AnyMember) => match(m) .with(P.tag("User"), (u) => `user:${u.email}`) - .with(P.tag("ServiceAccount"), (s) => `svc:${s.label}`) + .with(P.tag("ServiceAccount"), (s) => `svc:${s.name}`) .exhaustive(); ``` -## When to reach for `extend` instead +## When to reach for a root instead If the relationship is "the same thing with more fields" rather than "contains -a thing", extend rather than nest: +a thing", share a root rather than nest: ```ts -class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) {} +abstract class PersonBase extends Entity.abstract("Person")({ + id: PersonId, + name: Name, +}) {} + +class Person extends PersonBase.extend("Person")({}) {} +class PersonWithAge extends PersonBase.extend("PersonWithAge")({ age: Age }) {} ``` -That produces a new entity with its own tag and identity — not a variant of -`Person`, and not a subclass, which is -[refused](/explanation/sealed-construction#entities-are-not-subclassable). +Each variant is a genuine entity with its own tag, schemas and `equals` +identity. `PersonWithAge` is not a subclass of `Person` — an entity is +[final](/explanation/sealed-construction#an-entity-is-final) — but both are +instances of `PersonBase`, so code holding the root works on either. diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index 177cc28..7f9e2e1 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -1,6 +1,6 @@ --- title: Declaring an entity -description: Entity(tag)(fields, options), the field rules, the four options, and the Entity.computed / Entity.invariant / extend / union declaration helpers. +description: Entity(tag)(fields, options), the field rules, the four options, and the Entity.computed / Entity.invariant / Entity.abstract / Entity.union declaration helpers. --- # Declaring an entity @@ -25,6 +25,13 @@ Declares an entity. Curried on the tag so it reads next to the class name. class Organization extends Entity("Organization")(fields, options) {} ``` +The declared class is **final**. There is no `Organization.extend`, and a bare +`class Sub extends Organization {}` is +[rejected at construction](/explanation/sealed-construction#an-entity-is-final). +Fields shared by several entities go on an +[abstract root](#entity-abstract-name-fields-options); behaviour that belongs to +this one entity goes in its own class body. + ### `fields` A map of field name to schema. Every field must be **nominal** — a branded @@ -114,43 +121,130 @@ is already a Defect rather than something to re-check here. A predicate that throws is a Defect, not an `InvalidEntity`, on the same reasoning as `computed`. -## `SomeEntity.extend(tag)(fields, options?)` +## `Entity.abstract(name)(fields, options?)` + +A **root**: the fields and the behaviour several entities share, in a class that +is extended rather than instantiated. + +```ts +abstract class AccountBase extends Entity.abstract("Account")( + { id: AccountId, label: Label }, + { immutable: ["id"] }, +) { + abstract describe(): string; + + get slug(): string { + return this.label.toLowerCase(); + } +} +``` + +`fields` and `options` are exactly what `Entity(tag)(…)` takes — same field +rules, same four options — and both are inherited by every entity extended from +the root. + +A root is **not** an entity. It has no `make`, no `factory`, and none of the +four schema members; those belong to a variant, which has a tag to build them +under. Reaching its constructor is a defect: + +``` +Account: an abstract root has no instances — extend it and use make() +``` + +`name` is what labels the root in that message. It never reaches an instance: +a variant's `_tag` is the variant's own, and on the root's instance type `_tag` +is widened to `string` so shared behaviour can still read it. +([Why](/explanation/unions-and-roots#why-the-root-carries-no-tag).) + +| On a root | | +| ------------------------------------- | ------------------------------------------------------ | +| `abstract` members | enforced on every variant — `TS2515` if one is missing | +| `toJSON`, `equals`, `update` | callable, never overridable | +| `variant instanceof Root` | `true` | +| an intermediate `abstract class … {}` | inherited, fields and behaviour both | + +The three prototype methods are the one asymmetry: the entity's own prototype +sits above the root's, so a member declared under one of those names on a root +compiles and is silently never called. Behaviour that must differ per variant +goes in the variant's body — an `abstract` member on the root is how to require +it. + +An intermediate root is an ordinary abstract class, so behaviour can be layered +without another declaration: -A **new** entity carrying the parent's fields plus more, under its own tag — -its own schemas, its own `equals` identity. +```ts +abstract class Auditable extends AccountBase { + audit(): string { + return `${this._tag}:${this.id}`; + } +} + +class Business extends Auditable.extend("Business")({ + kind: z.literal("business"), +}) { + override describe(): string { + return `business ${this.slug}`; + } +} +``` + +### `Root.extend(tag)(fields, options?)` + +A **new** entity carrying the root's fields plus more, under its own tag — its +own schemas, its own `equals` identity — inheriting the class body of whatever +it was called on. ```ts -class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { - get isAdult(): boolean { - return this.age >= 18; +class Personal extends AccountBase.extend("Personal")({ + kind: z.literal("personal"), +}) { + override describe(): string { + return `personal ${this.slug}`; } } ``` Options merge per key, child winning — **except `invariants`**, which -concatenates parent-then-child. An extension can add rules; it cannot shed them, -so it is never quietly laxer than what it extends. Declaring `invariants: []` on -a child does not clear the parent's. +concatenates root-then-variant. A variant can add rules; it cannot shed them, so +it is never quietly laxer than its root. Declaring `invariants: []` on a variant +does not clear the root's. -`extend` rebuilds from the **declaration**, so class-body members do not carry -over — re-declare them. +Every other option **replaces** the root's for that key. A variant declaring +`generated` or `immutable` re-states every key it needs, including the root's; +one that declares neither inherits both lists whole. -This is the only supported way to build on an existing entity: a bare -`class Sub extends Organization {}` is -[rejected at construction](/explanation/sealed-construction#entities-are-not-subclassable). +`extend` lives only on a root. The entity it returns is final. ## `Entity.union(discriminant, members)` -A union of entities that is itself entity-like. +A union of entities, declared as a class. ```ts -const Member = Entity.union("kind", [User, ServiceAccount]); +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); + } +} + +Account.make(row); // Result +Account.input; // discriminated union, one branch per member +Account.output; // ditto — JSON Schema both directions +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).) -Member.make(row); // Result -Member.input; // discriminated union, one branch per member -Member.output; // ditto — JSON Schema both directions -Member.members; // the tuple, for registries and exhaustiveness -Member.discriminant; // "kind" +`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: + +``` +Personal | Business: a union has no instances — use make() ``` `discriminant` names a declared domain field, not `_tag`. The union dispatches diff --git a/docs/reference/types.md b/docs/reference/types.md index e85f202..368981c 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -1,6 +1,6 @@ --- title: Helper types -description: Entity.Input, Entity.Output, Entity.CreateInput, Entity.Patch — and the six declaration-emit names exported at the top level. +description: Entity.Input, Entity.Output, Entity.CreateInput, Entity.Patch, Entity.Instance — and the seven declaration-emit names exported at the top level. --- # Helper types @@ -17,8 +17,29 @@ type OrgCreate = Entity.CreateInput; // what a factory acce 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)): + +```ts +class Account extends Entity.union("kind", [Personal, Business]) {} + +type AnyAccount = Entity.Instance; // Personal | Business +type OnePersonal = Entity.Instance; // Personal +``` + +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. + +## The other namespace members + Also `Entity.ComputedField` and `Entity.Invariant`, the shapes `Entity.computed` -and `Entity.invariant` return; `Entity.Union`, what `Entity.union` returns; and +and `Entity.invariant` return; `Entity.Union`, what `Entity.union` returns; +`Entity.Abstract`, what `Entity.abstract(name)(fields, options)` returns; and `Entity.Static`, the full static surface `Entity(tag)(fields, options)` returns — the type of the anonymous class the declaration form extends. You rarely name any of them: the declaration helpers infer their parameters from the @@ -26,16 +47,17 @@ surrounding declaration. ## The declaration-emit names -Six types are exported at the top level: `BaseInstance`, `ConstructionKey`, -`EntityStatic`, `EntityUnion`, `Sealed`, `UnionMember`. They are the one -exception to the single-import rule, and none of them is part of the API you -write against. Five also have namespace aliases for anyone annotating by hand — -`Entity.BaseInstance`, `Entity.ConstructionKey`, `Entity.Sealed`, -`Entity.Static`, `Entity.Union` — but a consumer's _emitted declarations_ use -the top-level names. +Seven types are exported at the top level: `AbstractEntity`, `BaseInstance`, +`ConstructionKey`, `EntityStatic`, `EntityUnion`, `Sealed`, `UnionMember`. They +are the one exception to the single-import rule, and none of them is part of the +API you write against. Six also have namespace aliases for anyone annotating by +hand — `Entity.Abstract`, `Entity.BaseInstance`, `Entity.ConstructionKey`, +`Entity.Sealed`, `Entity.Static`, `Entity.Union` — but a consumer's _emitted +declarations_ use the top-level names. ```ts import type { + AbstractEntity, BaseInstance, ConstructionKey, EntityStatic, @@ -60,6 +82,10 @@ top-level name. What each one buys was measured, not assumed: (240 bytes with the name), a realistic enum crossed the serialisation ceiling (`TS7056`, issue #31), and a branded object field expanded until zod's module-private `$brand` symbol could not be named (`TS4020`, #32). +- **`AbstractEntity`** — the same story one declaration form over: a consumer + writing `abstract class X extends Entity.abstract("X")(…) {}` emits the + underlying name into its declarations, not the `Entity.Abstract` path that + aliases it. - **`EntityUnion`, `UnionMember`** — the same story for `Entity.union(...)` assigned to an exported `const`: without a top-level name the members expand structurally and reach `$brand`, failing with diff --git a/packages/entity/README.md b/packages/entity/README.md index e6fc9a3..c5fbe81 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -66,8 +66,31 @@ const renamed = loaded.update({ name: next }).getOrThrow(); // a NEW entity | `updateInput` | update request — `output` minus `immutable` and `computed`, partial | | _the class_ | parses to an instance; valid as a field | -Also `Entity.union(...)` for a union that is itself entity-like, and -`SomeEntity.extend(tag)(fields)` to build a new entity from an existing one. +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: + +```ts +abstract class AccountBase extends Entity.abstract("Account")({ + id: AccountId, + label: DisplayName, +}) { + abstract describe(): string; // every variant owes this — the compiler checks +} + +class Personal extends AccountBase.extend("Personal")({ + kind: z.literal("personal"), +}) { + override describe(): string { + return `personal ${this.label}`; + } +} + +// `Business` is declared the same way, on the same root +class Account extends Entity.union("kind", [Personal, Business]) {} + +Account.make(row); // Result +``` ## Documentation From b2f1de22a6f607370b22cb7d741d6347bbd9538c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 19:30:17 +0200 Subject: [PATCH 09/12] refactor(example): split the billing domain so the root crosses a module boundary The emit fixture only ever compiled a root declared beside its variants, where TypeScript synthesises a local `declare abstract class` for it. Split into vocabulary / organization / root / index so the two-compiler pass also covers the path where the root's instance type has to be named across a module boundary. The widths in `index.ts` are untouched: the emitted `Invoice_base` still expands the thirty-member dunning enum inline. --- examples/billing-domain/src/index.ts | 166 ++++---------------- examples/billing-domain/src/organization.ts | 35 +++++ examples/billing-domain/src/root.ts | 44 ++++++ examples/billing-domain/src/vocabulary.ts | 80 ++++++++++ 4 files changed, 188 insertions(+), 137 deletions(-) create mode 100644 examples/billing-domain/src/organization.ts create mode 100644 examples/billing-domain/src/root.ts create mode 100644 examples/billing-domain/src/vocabulary.ts diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index 03dbb0b..89c6466 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -1,147 +1,39 @@ /** * A small billing domain, modelled with `@btravstack/entity`. * - * Read it top to bottom: the field vocabulary first, then the entities — one - * standalone, and a root with two variants under a union — then the factories - * binding them to their effect sources. Every shape here is - * one a billing model actually needs — including the two that once broke - * declaration emit for consumers: a branded `Money` object, and a dunning - * vocabulary wide enough to matter. See `emit-guards.ts`. + * Four modules, in dependency order: `vocabulary.ts` (the branded field + * vocabulary), `organization.ts` (one standalone entity), `root.ts` (the + * abstract root the billing documents share) and this file — the two variants, + * the union over them, and the factories binding them to their effect sources. + * Every shape here is one a billing model actually needs — including the two + * that once broke declaration emit for consumers: a branded `Money` object, and + * a dunning vocabulary wide enough to matter. See `emit-guards.ts`. + * + * The root lives in its own module rather than beside its variants so the + * two-compiler declaration pass covers the cross-module case, which is the only + * one where the root has to be *named* rather than re-declared locally. The + * reason is measured, and inline in `root.ts`. */ import { Entity } from "@btravstack/entity"; import { z } from "zod"; -/* ── The field vocabulary ────────────────────────────────────────────── - Every data field is branded. A bare `z.string()` is a compile error, and - that is the point: an OrganizationId and a Slug are both strings, and the - model should not let you pass one where the other belongs. */ - -export const OrganizationId = z.uuid().brand("OrganizationId"); -export const InvoiceId = z.uuid().brand("InvoiceId"); -export const CreditNoteId = z.uuid().brand("CreditNoteId"); -export const Slug = z.string().min(1).max(40).brand("Slug"); -export const DisplayName = z.string().min(1).brand("DisplayName"); -export const DisplayLabel = z.string().min(1).brand("DisplayLabel"); -export const Instant = z.iso.datetime().brand("Instant"); -export const LineLabel = z.string().min(1).brand("LineLabel"); - -export const Currency = z.enum(["EUR", "USD", "GBP"]); - -/** - * A value object: no identity, so it is a *branded object* rather than an - * entity. Amounts are integer minor units — `12_00` is €12.00 — because binary - * floats are the wrong tool for money. - */ -export const Money = z.object({ amount: z.number().int(), currency: Currency }).brand("Money"); - -export const LineItem = z - .object({ label: LineLabel, unit: Money, quantity: z.number().int().positive() }) - .brand("LineItem"); - -export const InvoiceStatus = z.enum(["DRAFT", "ISSUED", "PAID", "VOID", "UNCOLLECTIBLE"]); - -/** Escalation step of a dunning run. */ -export const Level = z.union([ - z.literal(0), - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(4), - z.literal(5), -]); - -/** - * Why an invoice entered dunning. Vocabularies this wide are ordinary in - * billing — and this one is kept at full width deliberately, because it is what - * pins issue #31. Read the note in `emit-guards.ts` before trimming it. - */ -export const DunningReason = z.enum([ - "CANCELED_LEASE", - "TENANT_LEAVE_BALANCE_DONE", - "SUBROGATIVE_RECEIPT_TO_BE_SIGNED", - "SUBROGATIVE_RECEIPT_SIGNED", - "NO_RGI_CLAIM", - "VISALE", - "MONTHLY_PAYMENT", - "GROWTH", - "UNIT_SOLD", - "EXPENSE_TRANSFER", - "DECEASED_TENANT", - "DECEASED_COOWNER", - "DISPUTE_CHARGES", - "DISPUTE_REPAIRS", - "OWNER_INSTRUCTIONS_EXCLUDING_GLI", - "CHECK_OR_CASH_NOT_RECORDED", - "AWAITING_CAF_PAYMENT", - "NEW_BUILDING", - "NEW_COOWNER", - "MANAGEMENT_DIFFICULTIES", - "SALE_IN_PROGRESS", - "PROMISE_OF_PAYMENT", - "FALSE_DISTRIBUTIONS", - "INSTITUTIONAL_COOWNER", - "HISTORICAL", - "TENANT_LEAVE_NO_REMINDER", - "EXTERNAL_RGI_DISASTER", - "OVER_INDEBTEDNESS_LEGAL_PROCEEDINGS", - "MEMORANDUM_OF_AGREEMENT", - "MANUAL_EXPENSE_TRANSFER", -]); - -/* ── The entities ──────────────────────────────────────────────────────── */ - -/** - * `generated` names the fields the domain produces rather than the caller, so - * they drop out of `createInput`. `immutable` names the ones `update` refuses. - * `computed` is re-derived on every construction path, so it cannot drift from - * its sources. - */ -export class Organization extends Entity("Organization")( - { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, - { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], - computed: { - displayLabel: Entity.computed( - DisplayLabel, - (d) => `${d.name} (${d.slug})` as z.infer, - ), - }, - invariants: [ - Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), - ], - }, -) { - /** Behaviour goes in the class body — this is a real class. */ - get isSelfTitled(): boolean { - return this.name.toLowerCase().startsWith(this.slug.toLowerCase()); - } -} - -/** - * What every billing document shares. A root rather than a third entity: it is - * tagless, has no `make`, and exists to hold the fields and the behaviour the - * variants have in common. `Entity.abstract` is the only extensible declaration - * — an entity itself is final. - * - * `issuedTo` is another entity used directly as a field: the class is itself a - * zod schema, so it parses back to a real `Organization`, behaviour and all. - */ -abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( - { issuedTo: Organization, total: Money, issuedAt: Instant }, - { - generated: ["issuedAt"], - immutable: ["issuedAt", "issuedTo"], - invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], - }, -) { - /** The rule both variants owe the ledger, written once. */ - abstract signedAmount(): number; - - get counterpartySlug(): string { - return this.issuedTo.slug; - } -} +import { Organization } from "./organization.js"; +import { BillingDocumentBase } from "./root.js"; +import { + CreditNoteId, + DunningReason, + InvoiceId, + InvoiceStatus, + Level, + LineItem, +} from "./vocabulary.js"; +import type { Instant, OrganizationId } from "./vocabulary.js"; + +export * from "./organization.js"; +export * from "./root.js"; +export * from "./vocabulary.js"; + +/* ── The billing documents ─────────────────────────────────────────────── */ export class Invoice extends BillingDocumentBase.extend("Invoice")( { diff --git a/examples/billing-domain/src/organization.ts b/examples/billing-domain/src/organization.ts new file mode 100644 index 0000000..1792d85 --- /dev/null +++ b/examples/billing-domain/src/organization.ts @@ -0,0 +1,35 @@ +import { Entity } from "@btravstack/entity"; +import type { z } from "zod"; + +import { DisplayLabel, DisplayName, Instant, OrganizationId, Slug } from "./vocabulary.js"; + +/** + * `generated` names the fields the domain produces rather than the caller, so + * they drop out of `createInput`. `immutable` names the ones `update` refuses. + * `computed` is re-derived on every construction path, so it cannot drift from + * its sources. + * + * A plain, rootless entity: nothing else shares its fields, so there is nothing + * for a root to hold. + */ +export class Organization extends Entity("Organization")( + { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, + { + generated: ["id", "createdAt"], + immutable: ["id", "createdAt", "slug"], + computed: { + displayLabel: Entity.computed( + DisplayLabel, + (d) => `${d.name} (${d.slug})` as z.infer, + ), + }, + invariants: [ + Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), + ], + }, +) { + /** Behaviour goes in the class body — this is a real class. */ + get isSelfTitled(): boolean { + return this.name.toLowerCase().startsWith(this.slug.toLowerCase()); + } +} diff --git a/examples/billing-domain/src/root.ts b/examples/billing-domain/src/root.ts new file mode 100644 index 0000000..7c9d89b --- /dev/null +++ b/examples/billing-domain/src/root.ts @@ -0,0 +1,44 @@ +import { Entity } from "@btravstack/entity"; + +import { Organization } from "./organization.js"; +import { Instant, Money } from "./vocabulary.js"; + +/** + * What every billing document shares. A root rather than a third entity: it is + * tagless, has no `make`, and exists to hold the fields and the behaviour the + * variants have in common. `Entity.abstract` is the only extensible declaration + * — an entity itself is final. + * + * `issuedTo` is another entity used directly as a field: the class is itself a + * zod schema, so it parses back to a real `Organization`, behaviour and all. + * + * **Exported, and in a module of its own, on purpose.** A root's instance type + * is the sixth type argument of every variant's `EntityStatic`, so it lands in + * the emitted `.d.ts` of whatever module the variants are exported from — and + * it lands there two different ways. Kept beside its variants, TypeScript + * synthesises a local `declare abstract class`; across a module boundary it has + * to *name* the export, and `index.d.ts` opens with + * `import { BillingDocumentBase } from "./root.js"`. Only the first path was + * covered while this declaration sat in `index.ts`. Measured on both TypeScript + * 7.0.2 and 5.9.3: both paths emit clean, and `update`'s polymorphic `this` + * survives both as `Result` rather than degrading. + * + * The user-facing rule is simpler than the emit is: a root has to be exported + * for variants in another module to extend it at all, since `extend` is a call + * on the value. + */ +export abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( + { issuedTo: Organization, total: Money, issuedAt: Instant }, + { + generated: ["issuedAt"], + immutable: ["issuedAt", "issuedTo"], + invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], + }, +) { + /** What the document contributes to the ledger — declared once, signed per variant. */ + abstract signedAmount(): number; + + get counterpartySlug(): string { + return this.issuedTo.slug; + } +} diff --git a/examples/billing-domain/src/vocabulary.ts b/examples/billing-domain/src/vocabulary.ts new file mode 100644 index 0000000..9786e53 --- /dev/null +++ b/examples/billing-domain/src/vocabulary.ts @@ -0,0 +1,80 @@ +/** + * The field vocabulary. + * + * Every data field is branded. A bare `z.string()` is a compile error, and that + * is the point: an OrganizationId and a Slug are both strings, and the model + * should not let you pass one where the other belongs. + */ +import { z } from "zod"; + +export const OrganizationId = z.uuid().brand("OrganizationId"); +export const InvoiceId = z.uuid().brand("InvoiceId"); +export const CreditNoteId = z.uuid().brand("CreditNoteId"); +export const Slug = z.string().min(1).max(40).brand("Slug"); +export const DisplayName = z.string().min(1).brand("DisplayName"); +export const DisplayLabel = z.string().min(1).brand("DisplayLabel"); +export const Instant = z.iso.datetime().brand("Instant"); +export const LineLabel = z.string().min(1).brand("LineLabel"); + +export const Currency = z.enum(["EUR", "USD", "GBP"]); + +/** + * A value object: no identity, so it is a *branded object* rather than an + * entity. Amounts are integer minor units — `12_00` is €12.00 — because binary + * floats are the wrong tool for money. + */ +export const Money = z.object({ amount: z.number().int(), currency: Currency }).brand("Money"); + +export const LineItem = z + .object({ label: LineLabel, unit: Money, quantity: z.number().int().positive() }) + .brand("LineItem"); + +export const InvoiceStatus = z.enum(["DRAFT", "ISSUED", "PAID", "VOID", "UNCOLLECTIBLE"]); + +/** Escalation step of a dunning run. */ +export const Level = z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), +]); + +/** + * Why an invoice entered dunning. Vocabularies this wide are ordinary in + * billing — and this one is kept at full width deliberately, because it is what + * pins issue #31. Read the note in `emit-guards.ts` before trimming it. + */ +export const DunningReason = z.enum([ + "CANCELED_LEASE", + "TENANT_LEAVE_BALANCE_DONE", + "SUBROGATIVE_RECEIPT_TO_BE_SIGNED", + "SUBROGATIVE_RECEIPT_SIGNED", + "NO_RGI_CLAIM", + "VISALE", + "MONTHLY_PAYMENT", + "GROWTH", + "UNIT_SOLD", + "EXPENSE_TRANSFER", + "DECEASED_TENANT", + "DECEASED_COOWNER", + "DISPUTE_CHARGES", + "DISPUTE_REPAIRS", + "OWNER_INSTRUCTIONS_EXCLUDING_GLI", + "CHECK_OR_CASH_NOT_RECORDED", + "AWAITING_CAF_PAYMENT", + "NEW_BUILDING", + "NEW_COOWNER", + "MANAGEMENT_DIFFICULTIES", + "SALE_IN_PROGRESS", + "PROMISE_OF_PAYMENT", + "FALSE_DISTRIBUTIONS", + "INSTITUTIONAL_COOWNER", + "HISTORICAL", + "TENANT_LEAVE_NO_REMINDER", + "EXTERNAL_RGI_DISASTER", + "OVER_INDEBTEDNESS_LEGAL_PROCEEDINGS", + "MEMORANDUM_OF_AGREEMENT", + "MANUAL_EXPENSE_TRANSFER", +]); From 898332388873c97e78f4ea404fb4b32e9e396ad9 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 19:31:05 +0200 Subject: [PATCH 10/12] test: pin what a root does not carry into a variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps: a root's class-body field is never initialised and its statics are not inherited (`extend` rewires the instance prototype only), and a variant declaring `computed` replaces the root's derived fields rather than adding to them. Plus the three cheap ones the review named — the shadowing test asserted only `equals`, `Business.describe()` was never called, and nothing replaced the deleted `extend.spec.ts` assertion that two variants with matching data are not equal. --- packages/entity/src/base.spec.ts | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index f74c161..afca87b 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -73,6 +73,46 @@ test("a behaviour-only intermediate root is picked up", () => { const b = Business.make({ id, label: "Acme", kind: "business", vat: "FR1" }).getOrThrow(); expect(b.audit()).toBe("Business:" + id); expect(b.slug).toBe("acme"); + // the variant's own body reads a field from its own `extend()` call + expect(b.describe()).toBe("business FR1"); +}); + +test("two sibling variants of one root are never equal", () => { + const p = Personal.make({ id, label: "Ada", kind: "personal" }).getOrThrow(); + const b = Business.make({ id, label: "Ada", kind: "business", vat: "FR1" }).getOrThrow(); + // prototype rewiring makes both `instanceof AccountBase`; identity must still + // be per-entity, so matching data across two variants is not equality + expect(p.equals(b)).toBe(false); + expect(b.equals(p)).toBe(false); +}); + +test("a root's class-body *field* is never initialised", () => { + abstract class WithField extends Entity.abstract("WithField")({ id: AccountId }) { + counter = 0; + } + class Counted extends WithField.extend("Counted")({ label: Label }) {} + const c = Counted.make({ id, label: "Ada" }).getOrThrow(); + // `extend` rewires the *instance prototype* only, and the variant's generated + // base extends nothing — so the root's constructor never runs and a field + // initialiser never fires. Typed `number`, absent at runtime. Use a getter or + // a method on a root; a field cannot be prevented at the type level, because + // mapping `BehaviourOf` is what TS2425 forbids. + expect("counter" in c).toBe(false); + expect((c as unknown as { counter: unknown }).counter).toBeUndefined(); +}); + +test("a root's statics are not inherited by a variant", () => { + abstract class WithStatic extends Entity.abstract("WithStatic")({ id: AccountId }) { + static hello(): string { + return "hi"; + } + } + class Quiet extends WithStatic.extend("Quiet")({ label: Label }) {} + // instance-prototype rewiring only: the *static* chain is untouched, so a + // root's statics stay on the root. The type side agrees, so this surfaces as + // a compile error rather than a crash. + expect(Object.getPrototypeOf(Quiet)).not.toBe(WithStatic); + expect((Quiet as unknown as { hello?: unknown }).hello).toBeUndefined(); }); test("the root's options carry over", () => { @@ -102,6 +142,25 @@ test("a variant option overrides the root's for that key", () => { expect(Object.keys(Loose.updateInput.shape).toSorted()).toEqual(["id", "label", "note"]); }); +test("a variant declaring computed replaces the root's, it does not add to them", () => { + class Quiet extends AccountBase.extend("Quiet")( + { note: Label }, + { + computed: { + murmur: Entity.computed(Label, (d) => d.label.toLowerCase() as z.infer), + }, + }, + ) { + override describe(): string { + return "quiet"; + } + } + // `computed` replaces like every option but `invariants`, so the root's + // `shout` is gone — a derived column is easy to lose this way. Re-state the + // root's entries alongside the variant's to keep both. + expect(Object.keys(Quiet.output.shape).toSorted()).toEqual(["id", "label", "murmur", "note"]); +}); + test("invariants are the exception: a variant adds to the root's, never replaces", () => { const Score = z.number().int().brand("Score"); class Stricter extends AccountBase.extend("Stricter")( @@ -132,6 +191,12 @@ test("an entity's own toJSON/equals/update shadow a root's", () => { override equals(): boolean { return true; } + override toJSON(): never { + return "hijacked" as never; + } + override update(): never { + return "hijacked" as never; + } } class Shadowed extends Shadowing.extend("Shadowed")({ label: Label }) {} const a = Shadowed.make({ id, label: "a" }).getOrThrow(); @@ -139,6 +204,8 @@ test("an entity's own toJSON/equals/update shadow a root's", () => { // the root sits *below* the entity's own prototype in the chain, so a root // can call these three but never override them expect(a.equals(b)).toBe(false); + expect(a.toJSON()).toEqual({ id, label: "a" }); + expect(a.update({ label: "c" as z.infer }).getOrThrow().label).toBe("c"); }); test("a root has no instances", () => { From d48f670381cb7f68dea99e57210eff29322be150 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 19:35:58 +0200 Subject: [PATCH 11/12] docs: state what a root does not carry, and re-attach the union's doc block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings the review named. A root's class-body fields are never initialised and its statics are not inherited — `extend` rewires the instance prototype only — so `declaration.md` now says both, alongside where a root goes and which options replace rather than merge (`computed` included). `union.ts`'s module doc still taught the removed value form and was orphaned between two adjacent JSDoc blocks, so `union` rendered undocumented; it now sits on the function and shows the class form. Plus the smaller ones: `__base` is the root's *instance* type, a union of members from different roots shares nothing even when the roots are related, an intermediate cannot add fields, and CLAUDE.md's module count. --- CLAUDE.md | 19 +++++- docs/examples/billing-domain.md | 47 +++++++++++---- docs/explanation/unions-and-roots.md | 20 ++++++- docs/reference/declaration.md | 87 ++++++++++++++++++++++------ packages/entity/src/types.ts | 2 +- packages/entity/src/union.ts | 44 ++++++++------ 6 files changed, 164 insertions(+), 55 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa32f8c..9425c5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,11 @@ that are not derivable from there: shape and reported only `TS4020`. Widen the entity and both report it. So a band of realistic domain widths fails for consumers and passes here — which is the band issues #31 and #32 shipped through. + That example keeps its abstract root in `src/root.ts`, exported, rather than + beside its variants: a root reaches a variant's `.d.ts` as a synthesised local + `declare abstract class` when the two share a module and as a **named import** + when they do not, and only the first path was compiled while the root lived in + `index.ts`. - **A `paths` mapping is not how the emit fixture resolves the package.** `examples/billing-domain` depends on `@btravstack/entity` as `workspace:*` and reaches `dist/index.d.mts` through its real `exports`, the way an actual @@ -67,7 +72,8 @@ cannot run against it. Measured — the reason is inline in ## Architecture -Eleven source modules under `packages/entity/src`, split by what they own: +Twelve source modules under `packages/entity/src` besides `index.ts`, split by +what they own: - **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from @@ -95,8 +101,15 @@ Eleven source modules under `packages/entity/src`, split by what they own: prototype onto the receiver's — which is what makes `variant instanceof Root` true, picks up a behaviour-only intermediate root, and leaves the entity's own `toJSON`/`equals`/`update` shadowing anything a root declares under those - names. Options merge per key, child winning, except `invariants`, which - concatenate. Built against a loosened `BuildEntity` passed in from + names. The rewiring is **instance-prototype only** — one `setPrototypeOf` on + `child.prototype` — and that single fact explains the rest: a root's + `static` members are not inherited (the static chain is untouched), a root's + class-body **field** is typed but never initialised (the variant's generated + base extends nothing, so a root's constructor never runs), and the + construction seal is unaffected. `docs/reference/declaration.md` states all + three; `base.spec.ts` pins them. Options merge per key, child winning, except + `invariants`, which concatenate — so a variant declaring `computed` **drops** + the root's derived fields. Built against a loosened `BuildEntity` passed in from `entity.ts`, so this module imports no builder and there is no cycle. - **`equal.ts`** — `deepEqual`, the primitive behind `equals`. Not `JSON.stringify`: that **threw** on a `bigint` field, compared `Set`/`Map`/ diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 28906e7..372567f 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -13,6 +13,11 @@ union, and the vocabulary they are all built from. pnpm --filter @btravstack/entity-example-billing-domain test ``` +Four modules, in dependency order: `vocabulary.ts`, `organization.ts`, +`root.ts`, and `index.ts` — the two variants, the union over them, and the +factories. The root sits in a module of its own on purpose; +[why](#three-things-in-this-package-that-look-odd-on-purpose). + ## The vocabulary comes first ```ts @@ -84,7 +89,10 @@ share goes on an `Entity.abstract` root — tagless, with no `make` of its own, extended rather than instantiated: ```ts -abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( +// root.ts — exported, so entities in another module can extend it +export abstract class BillingDocumentBase extends Entity.abstract( + "BillingDocument", +)( { issuedTo: Organization, total: Money, issuedAt: Instant }, { generated: ["issuedAt"], @@ -97,7 +105,7 @@ abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( ], }, ) { - /** the rule both variants owe the ledger */ + /** what the document contributes to the ledger — declared once, signed per variant */ abstract signedAmount(): number; get counterpartySlug(): string { @@ -105,11 +113,13 @@ abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( } } +// index.ts export class Invoice extends BillingDocumentBase.extend("Invoice")( { id: InvoiceId, kind: z.literal("INVOICE") /* … */ }, { generated: ["id", "issuedAt", "kind"], immutable: ["id", "issuedAt", "issuedTo", "kind"], + /* … invariants, one of them */ }, ) { override signedAmount(): number { @@ -119,15 +129,18 @@ export class Invoice extends BillingDocumentBase.extend("Invoice")( ``` `abstract signedAmount()` is the point of the root: a variant that forgets it -does not compile (`TS2515`). `counterpartySlug` is the other half — behaviour -written once and inherited, which is what a rebuilt-from-the-declaration -extension could not carry. An entity itself is final; `extend` lives only here. - -Note what the variants re-state. `generated` and `immutable` **replace** the -root's list for that key rather than adding to it, so `Invoice` names `issuedAt` -and `issuedTo` again alongside its own. Only `invariants` concatenate — the -root's "total must not be negative" applies to both variants whether or not they -declare rules of their own. +does not compile (`TS2515`). It is _declared_ once and _implemented_ twice, with +opposite sign — a credit note returns `-this.total.amount`. `counterpartySlug` +is the other half: behaviour written once and inherited, which is what a +rebuilt-from-the-declaration extension could not carry. An entity itself is +final; `extend` lives only here. + +Note what the variants re-state. `generated`, `immutable` and `computed` +**replace** the root's list for that key rather than adding to it, so `Invoice` +names `issuedAt` and `issuedTo` again alongside its own. Only `invariants` +concatenate — the root's "total must not be negative" applies to both variants +whether or not they declare rules of their own, and `Invoice` does declare one +of its own ("a void invoice cannot be in dunning"). ## Nesting, and the factory @@ -153,7 +166,17 @@ export const createOrganization = Organization.factory({ That is what leaves the entities trivially testable: nothing inside them reaches for ambient state. -## Two things in this package that look odd on purpose +## Three things in this package that look odd on purpose + +**The root is exported, and alone in `root.ts`.** A root's instance type is the +last type argument of every variant's `Entity.Static`, so it reaches the `.d.ts` +of whatever module the variants are exported from — and it reaches it two +different ways. Beside its variants, TypeScript synthesises a local +`declare abstract class`; across a module boundary it has to _name_ the export, +and `index.d.ts` opens with `import { BillingDocumentBase } from "./root.js"`. +While the root sat in `index.ts`, only the first path was ever compiled. Both +are clean on TypeScript 7.0.2 and 5.9.3 — the split is what keeps the second one +that way. **`DunningReason` has thirty members.** Vocabularies that wide are ordinary in billing, and this one is held at full width because it pins diff --git a/docs/explanation/unions-and-roots.md b/docs/explanation/unions-and-roots.md index 2529ab9..36f8624 100644 --- a/docs/explanation/unions-and-roots.md +++ b/docs/explanation/unions-and-roots.md @@ -32,9 +32,23 @@ 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 declared from different roots, or from no root -at all, share nothing, and the union claims the empty type rather than a -supertype that does not exist. +which the rule accepts. Members that do not share one claim the empty type +instead, rather than a supertype that does not exist. + +"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 + +```ts +class Personal extends AccountBase.extend("Personal")({ … }) {} +class Business extends Auditable.extend("Business")({ … }) {} // Auditable extends AccountBase +``` + +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. The exact union is not lost, only spelled elsewhere: diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index 7f9e2e1..faa8ed0 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -156,18 +156,44 @@ a variant's `_tag` is the variant's own, and on the root's instance type `_tag` is widened to `string` so shared behaviour can still read it. ([Why](/explanation/unions-and-roots#why-the-root-carries-no-tag).) -| On a root | | -| ------------------------------------- | ------------------------------------------------------ | -| `abstract` members | enforced on every variant — `TS2515` if one is missing | -| `toJSON`, `equals`, `update` | callable, never overridable | -| `variant instanceof Root` | `true` | -| an intermediate `abstract class … {}` | inherited, fields and behaviour both | - -The three prototype methods are the one asymmetry: the entity's own prototype -sits above the root's, so a member declared under one of those names on a root -compiles and is silently never called. Behaviour that must differ per variant -goes in the variant's body — an `abstract` member on the root is how to require -it. +| On a root | | +| ----------------------------------------------------- | ------------------------------------------------------ | +| `abstract` members | enforced on every variant — `TS2515` if one is missing | +| methods and getters | inherited by every variant | +| **class-body fields** (`count = 0`) | typed, but **never initialised** — see below | +| **statics** (`static of() {}`) | **not** inherited; they stay on the root | +| `toJSON`, `equals`, `update` | callable, never overridable | +| `variant instanceof Root` | `true` | +| an intermediate `abstract class … {}` (no new fields) | inherited, behaviour and the root's fields both | + +`extend` rewires the **instance prototype** and nothing else — one +`setPrototypeOf` on the new entity's prototype. That single fact is behind every +row above that is not plain inheritance. + +A class-body **field** is never initialised. The variant's generated base +extends nothing, so a root's constructor never runs and no field initialiser +fires: + +```ts +abstract class WithField extends Entity.abstract("WithField")({ id }) { + counter = 0; // typed `number`; `undefined` at runtime, and not an own property +} +``` + +Use a **getter or a method** for anything a root needs to expose — a `private +cache = new Map()` on a root is silently `undefined` in every variant. The type +level cannot catch it: mapping the root's instance type is exactly what +`TS2425` forbids, so the field's declared type survives into the variant. + +**Statics are not inherited either**, for the same reason: the static chain is +untouched, so `Root.of(…)` is not `Variant.of(…)`. The type side agrees, so +this surfaces as a compile error rather than an `undefined is not a function`. + +The three prototype methods are the one asymmetry in the other direction: the +entity's own prototype sits above the root's, so a member declared under one of +those names on a root compiles and is silently never called. Behaviour that must +differ per variant goes in the variant's body — an `abstract` member on the root +is how to require it. An intermediate root is an ordinary abstract class, so behaviour can be layered without another declaration: @@ -188,11 +214,33 @@ class Business extends Auditable.extend("Business")({ } ``` +An intermediate adds behaviour only. There is no way to declare further fields +on one — fields come from a root's `fields` map and a variant's `extend` call, +and nothing in between. + +### Where a root goes + +A root has to be **exported** for entities in another module to extend it: +`extend` is a call on the value, so an unexported root can only be extended +inside its own module. + +Both arrangements work, and this repo compiles both, because they emit +differently. A root's instance type is the last type argument of every variant's +`Entity.Static`, so it reaches the `.d.ts` of whatever module the variants are +exported from — kept beside its variants, TypeScript synthesises a local +`declare abstract class` for it; across a module boundary, the emitted +declaration has to name the export, and opens with +`import { AccountBase } from "./root.js"`. +[`examples/billing-domain`](/examples/billing-domain) splits the two apart so +the second path is covered by the two-compiler declaration pass. + ### `Root.extend(tag)(fields, options?)` A **new** entity carrying the root's fields plus more, under its own tag — its -own schemas, its own `equals` identity — inheriting the class body of whatever -it was called on. +own schemas, its own `equals` identity — inheriting the **instance** half of the +class body of whatever it was called on: its methods and accessors, but not its +statics and not its field initialisers +([above](#entity-abstract-name-fields-options)). ```ts class Personal extends AccountBase.extend("Personal")({ @@ -209,9 +257,14 @@ concatenates root-then-variant. A variant can add rules; it cannot shed them, so it is never quietly laxer than its root. Declaring `invariants: []` on a variant does not clear the root's. -Every other option **replaces** the root's for that key. A variant declaring -`generated` or `immutable` re-states every key it needs, including the root's; -one that declares neither inherits both lists whole. +Every other option — `generated`, `immutable` **and `computed`** — **replaces** +the root's for that key. A variant declaring one of them re-states every entry +it needs, including the root's; one that declares none inherits all three whole. + +`computed` is the one worth watching, because what it drops is a column rather +than a rule: a variant that declares a derived field of its own loses every +derived field the root declared, and the loss is only visible in +`Variant.output.shape`. `extend` lives only on a root. The entity it returns is final. diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 05afa89..22520d8 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -385,7 +385,7 @@ export type EntityStatic< readonly __output: OutputOf; readonly __createInput: CreateInputOf; readonly __patch: PatchOf; - /** the abstract root this was extended from, read by `Entity.union` */ + /** 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; diff --git a/packages/entity/src/union.ts b/packages/entity/src/union.ts index 0766507..09a1bb9 100644 --- a/packages/entity/src/union.ts +++ b/packages/entity/src/union.ts @@ -12,7 +12,7 @@ export type UnionMember = { readonly entityName: string; readonly input: z.ZodObject; readonly output: z.ZodObject; - /** the abstract root the member was extended from, or the empty type */ + /** 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; @@ -91,24 +91,6 @@ export type EntityUnion = { */ 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. - */ /** * Every value of a member's discriminant field. * @@ -149,6 +131,30 @@ const discriminantValues = (member: UnionMember, discriminant: string): readonly return []; }; +/** + * 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: + * + * ```ts + * class Member extends union("kind", [User, ServiceAccount]) {} + * 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. + * + * `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[]], From 316adbe76ea2893553f30e69c56a9221772e4afb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sat, 8 Aug 2026 19:50:07 +0200 Subject: [PATCH 12/12] test: make the sibling-variant equality pin discriminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The successor to extend.spec.ts's "even with matching data" compared Personal and Business, which differ in `kind` — so `deepEqual` returned false before `equals`' `instanceof Base` guard mattered, and the test passed with the guard deleted. Two variants of one root adding the same field under the same schema, with identical data, leave the guard as the only thing that can say no. Also pins the `private` field case the docs claim, and corrects three comments the split left stale: emit-guards.ts named `index.ts` for widths now in `vocabulary.ts`, and `extend`'s published JSDoc still said it inherits "the class body". --- docs/examples/billing-domain.md | 5 ++- docs/reference/declaration.md | 13 ++++-- examples/billing-domain/src/emit-guards.ts | 15 ++++--- packages/entity/src/base.spec.ts | 47 ++++++++++++++++++---- packages/entity/src/types.ts | 5 ++- 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 372567f..1e74ffc 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -136,8 +136,9 @@ rebuilt-from-the-declaration extension could not carry. An entity itself is final; `extend` lives only here. Note what the variants re-state. `generated`, `immutable` and `computed` -**replace** the root's list for that key rather than adding to it, so `Invoice` -names `issuedAt` and `issuedTo` again alongside its own. Only `invariants` +**replace** the root's entry for that key rather than adding to it — two key +lists and a map of derived fields — so `Invoice` names `issuedAt` and +`issuedTo` again alongside its own. Only `invariants` concatenate — the root's "total must not be negative" applies to both variants whether or not they declare rules of their own, and `Invoice` does declare one of its own ("a void invoice cannot be in dunning"). diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index faa8ed0..3cc85ce 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -180,10 +180,15 @@ abstract class WithField extends Entity.abstract("WithField")({ id }) { } ``` -Use a **getter or a method** for anything a root needs to expose — a `private -cache = new Map()` on a root is silently `undefined` in every variant. The type -level cannot catch it: mapping the root's instance type is exactly what -`TS2425` forbids, so the field's declared type survives into the variant. +Use a **getter or a method** for anything a root needs to hold. The type level +cannot catch the field form: mapping the root's instance type is exactly what +`TS2425` forbids, so the field's declared type survives into the variant intact. + +Visibility makes no difference, and `private` is the worst version of it. A +`private cache = new Map()` on a root compiles, in the root and in every +variant; the field is `undefined` at runtime; and the first root method that +reads it fails at the point of use rather than at the declaration — +measured: `TypeError: Cannot read properties of undefined (reading 'size')`. **Statics are not inherited either**, for the same reason: the static chain is untouched, so `Root.of(…)` is not `Variant.of(…)`. The type side agrees, so diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index f0778f7..49cf7fa 100644 --- a/examples/billing-domain/src/emit-guards.ts +++ b/examples/billing-domain/src/emit-guards.ts @@ -14,11 +14,16 @@ * signal. Every member of `Entity` is therefore named below, so * declaration emit has to walk each one. * - * 2. **The widths in `index.ts` are load bearing.** `TS7056` is a threshold on - * serialised *characters*, so `Invoice` needs its full dunning vocabulary, - * its branded timestamp and its six-member level union to stay above it. - * Measured: trimming them put the old fixture back under the ceiling, - * where it compiled happily and guarded nothing. + * 2. **The widths in `vocabulary.ts` are load bearing.** `TS7056` is a + * threshold on serialised *characters*, so `Invoice` — declared in + * `index.ts`, built from those schemas — needs its full dunning + * vocabulary, its branded timestamp and its six-member level union to stay + * above it. Measured: trimming them put the old fixture back under the + * ceiling, where it compiled happily and guarded nothing. Splitting the + * declarations out of `index.ts` did *not* shrink them — the emitter + * expands each schema in anonymous type-argument position rather than + * naming the binding, so `Invoice_base` still carries all thirty members + * inline. * * What went wrong when nothing checked this: `EntityStatic` was unexported, so * TypeScript had no name to write for the builder's return type and serialised diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index afca87b..5b6fcb8 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -77,13 +77,30 @@ test("a behaviour-only intermediate root is picked up", () => { expect(b.describe()).toBe("business FR1"); }); -test("two sibling variants of one root are never equal", () => { - const p = Personal.make({ id, label: "Ada", kind: "personal" }).getOrThrow(); - const b = Business.make({ id, label: "Ada", kind: "business", vat: "FR1" }).getOrThrow(); - // prototype rewiring makes both `instanceof AccountBase`; identity must still - // be per-entity, so matching data across two variants is not equality - expect(p.equals(b)).toBe(false); - expect(b.equals(p)).toBe(false); +test("two sibling variants of one root are never equal, even with matching data", () => { + // Deliberately indistinguishable *as data*: one root, two variants adding the + // same field under the same schema, and identical values. Both projections + // are `{ id, label, note }` with equal contents, so `deepEqual` says yes and + // only `equals`' `instanceof Base` guard can say no. That guard is the whole + // subject here — prototype rewiring makes both `instanceof Twinned`, so + // without it a sibling variant would pass as the same entity. + abstract class Twinned extends Entity.abstract("Twinned")({ + id: AccountId, + label: Label, + }) {} + class Left extends Twinned.extend("Left")({ note: Label }) {} + class Right extends Twinned.extend("Right")({ note: Label }) {} + + const left = Left.make({ id, label: "Ada", note: "n" }).getOrThrow(); + const right = Right.make({ id, label: "Ada", note: "n" }).getOrThrow(); + + // the data really is identical — this is what makes the assertions below bite + expect(left.toJSON()).toEqual(right.toJSON()); + expect(left).toBeInstanceOf(Twinned); + expect(right).toBeInstanceOf(Twinned); + + expect(left.equals(right)).toBe(false); + expect(right.equals(left)).toBe(false); }); test("a root's class-body *field* is never initialised", () => { @@ -101,6 +118,22 @@ test("a root's class-body *field* is never initialised", () => { expect((c as unknown as { counter: unknown }).counter).toBeUndefined(); }); +test("a private field on a root fails where it is read, not where it is declared", () => { + abstract class Cached extends Entity.abstract("Cached")({ id: AccountId }) { + private cache = new Map(); + peek(): number { + return this.cache.size; + } + } + class Peeking extends Cached.extend("Peeking")({ label: Label }) {} + const p = Peeking.make({ id, label: "Ada" }).getOrThrow(); + // `private` changes nothing: it compiles in the root and in the variant, and + // the field is still never initialised. The declaration is silent; the read + // is not. This is what `docs/reference/declaration.md` pins. + expect("cache" in p).toBe(false); + expect(() => p.peek()).toThrow(TypeError); +}); + test("a root's statics are not inherited by a variant", () => { abstract class WithStatic extends Entity.abstract("WithStatic")({ id: AccountId }) { static hello(): string { diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 22520d8..fa9d388 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -295,7 +295,10 @@ export type AbstractEntity< readonly entityName: Name; /** * A new entity carrying this root's fields plus more, under its own tag, and - * inheriting the class body of whatever it was called on. + * inheriting the **instance** half of the class body of whatever it was + * called on: its methods and accessors, but not its statics and not its field + * initialisers. `extend` rewires the instance prototype and nothing else, so + * a root's constructor never runs — see `docs/reference/declaration.md`. * * The `this` parameter is what picks up a behaviour-only intermediate root: * `abstract class Auditable extends AccountBase { … }` then