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..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 -Ten 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 @@ -80,11 +86,31 @@ 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. 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`/ typed-array fields with different contents as **equal**, and reported a @@ -99,7 +125,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 +137,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 +174,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 +194,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..1e74ffc 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -1,17 +1,23 @@ --- 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 ``` +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 @@ -75,10 +81,73 @@ 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 +// 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"], + 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; + } +} + +// 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 { + return this.total.amount; + } +} +``` + +`abstract signedAmount()` is the point of the root: a variant that forgets it +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 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"). + ## 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(); @@ -98,7 +167,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 @@ -116,12 +195,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..36f8624 --- /dev/null +++ b/docs/explanation/unions-and-roots.md @@ -0,0 +1,141 @@ +--- +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 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: + +```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..3cc85ce 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,188 @@ 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 **new** entity carrying the parent's fields plus more, under its own tag — -its own schemas, its own `equals` identity. +A **root**: the fields and the behaviour several entities share, in a class that +is extended rather than instantiated. ```ts -class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) { - get isAdult(): boolean { - return this.age >= 18; +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 | +| 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 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 +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: + +```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}`; + } +} +``` + +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 **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")({ + 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 — `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. -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). +`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. ## `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); + } +} -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" +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).) + +`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/docs/typedoc.json b/docs/typedoc.json index 012b7be..2ac4310 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,7 +32,9 @@ "OnlyNominal", "OutputOf", "PatchOf", + "RootInstance", "SealedSrc", + "SharedBase", "UpdateInputShapeOf" ] } diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index 6ef249d..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 @@ -34,7 +39,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 +77,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..6312a9c 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -95,6 +95,41 @@ 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("an invoice carries 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", async () => { + // `CreditNote` no longer spells out "total must not be negative" — the root + // does. An extension can add rules; it cannot shed them. + 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(message).toBe("total must not be negative"); +}); + /* ── 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..89c6466 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -1,143 +1,56 @@ /** * 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 - * 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()); - } -} - -/** - * `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")( +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")( { 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` 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: [ - 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 +58,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 +69,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 +95,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 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", +]); 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 diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts new file mode 100644 index 0000000..5b6fcb8 --- /dev/null +++ b/packages/entity/src/base.spec.ts @@ -0,0 +1,257 @@ +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"); + // 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, 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", () => { + 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 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 { + 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", () => { + 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("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("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")( + { 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 { + 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(); + 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); + 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", () => { + 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..8bb8a81 --- /dev/null +++ b/packages/entity/src/base.test-d.ts @@ -0,0 +1,97 @@ +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'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(...) + 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 new file mode 100644 index 0000000..b4c92a8 --- /dev/null +++ b/packages/entity/src/base.ts @@ -0,0 +1,139 @@ +import type { ComputedField } from "./computed.js"; +import type { Invariant } from "./invariant.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 = ( + 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 } : {}), + }, + ); +}; + +/** + * `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.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 f8ea3e4..ea79a86 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 { createBase, record } from "./base.js"; import { computed, type ComputedField } from "./computed.js"; import { deepEqual } from "./equal.js"; import { InvalidEntity } from "./errors.js"; @@ -11,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, @@ -44,16 +47,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 +425,7 @@ 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); return Base as unknown as EntityStatic; }; @@ -487,6 +442,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 @@ -518,6 +474,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, @@ -578,4 +541,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/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/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..fa9d388 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -245,6 +245,89 @@ 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 **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 + * `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 +346,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,47 +381,20 @@ 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 *instance type* of the abstract root this was extended from, read by `Entity.union` */ + readonly __base: B; + /** the instance type, read by `Entity.Instance` */ + readonly __instance: ConstructedInstance & B; make(this: new (d: Sealed>) => T, state: unknown): Result; - /** - * 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, 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..09a1bb9 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 *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; @@ -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">; @@ -40,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. * @@ -98,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[]], @@ -177,16 +234,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; }