diff --git a/.changeset/extend-options-accumulate.md b/.changeset/extend-options-accumulate.md new file mode 100644 index 0000000..3ec7b14 --- /dev/null +++ b/.changeset/extend-options-accumulate.md @@ -0,0 +1,85 @@ +--- +"@btravstack/entity": minor +--- + +`extend` options now accumulate instead of replacing. `generated` and +`immutable` concatenate root-then-variant, and `computed` merges per key — the +rule `invariants` already followed. A variant adds to what its root declared and +can no longer shed it. + +Before, a variant that declared `immutable` replaced the root's list wholesale, +so this silently made `issuedAt` and `issuedTo` patchable: + +```ts +// root +abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( + fields, + { immutable: ["issuedAt", "issuedTo"] }, +) {} +// variant — before this change, the root's two were gone, with no diagnostic +class Invoice extends BillingDocumentBase.extend("Invoice")(fields, { + immutable: ["id", "kind"], +}) {} +``` + +Now the variant's effective list is all four, and re-stating inherited keys is +unnecessary — delete them. + +`computed` merges per key rather than concatenating, because it is a map: a +variant may add a derived field beside the root's, and may redefine one, but +cannot drop it. A redefined key gives the variant's schema and derivation on +`output.shape`, `toJSON()` and `Entity.Output`. One measured caveat: the +**instance** property keeps the root's type intersected in, because a root's +instance type is carried into every variant unmapped and subtracting from it is +what `TS2425` forbids. Read a redefined key off `Entity.Output` where its exact +type matters. + +**Breaking, in two ways.** + +Relaxing is no longer expressible: `immutable: []` in a variant does not widen +`updateInput`. Code relying on it breaks loudly — `updateInput` shrinks, so the +patch call stops typechecking rather than changing behaviour silently. To fix +it, move the key the other way: a field only some variants need locked comes off +the root's `immutable` and goes on each variant that wants it locked. The end +state is the same, and it is the only direction still expressible — a variant +can add to what the root declared, never subtract from it. + +`Entity.Static<…>`'s fourth and fifth arguments are now unions of keys rather +than tuples, so the empty case is `never`: + +```ts +// before +type Before = Entity.Static< + "Organization", + { slug: typeof Slug }, + Record, + [], + [] +>; +// after +type After = Entity.Static< + "Organization", + { slug: typeof Slug }, + Record, + never, + never +>; +``` + +The tuple form could not express the merge — `readonly [...I, ...I2]` is +rejected with `TS2344`, because TypeScript will not prove the parent's key set is +a subset of the child's through zod's inference chain. + +The same `TS2344` loosens the constraint on both. `Entity.Static` and +`Entity.BaseInstance` now take any `PropertyKey` where they previously required a +tuple constrained to `keyof`; tightening one back on its own reintroduces the +error, so it is not fixable asymmetrically. Hand-written entity declarations are +unaffected — the builders still constrain the real call sites — but both are +named in consumers' emitted declarations, which is why it is listed here. + +For the same reason there is one new exported name, `MergedComputed` (and +`Entity.MergedComputed`): it is what `extend` hands `Entity.Static` as its +computed map, so it lands in the `.d.ts` of any library that declares a variant. +Not something to write against — written inline, the merge emitted an +unsubstituted type parameter and failed consumers on TypeScript 5.9.3 with +`TS2304: Cannot find name 'A2'`. diff --git a/CLAUDE.md b/CLAUDE.md index 9425c5a..412eef4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,12 @@ 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. + A fourth step then **type-checks the emitted `node_modules/.emit-check` with + 5.9.3**, because emitting cleanly is not the same as emitting something that + compiles: a dangling type-parameter reference in the output is no emit-time + diagnostic, and one shipped that way (`TS2304`, a bare `A2` from `extend`'s + return type). Never give that step `--skipLibCheck` — it disables `.d.ts` + checking outright and the run passes on broken output. Measured. 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** @@ -107,10 +113,12 @@ what they own: 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. + three; `base.spec.ts` pins them. Every option **accumulates** root-then-child: + `generated`, `immutable` and `invariants` concatenate, and `computed` merges + **per key**, so a variant can add or redefine a derived field but never drop + the root's. Relaxing is not expressible — `immutable: []` on a variant is a + no-op. 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 @@ -174,17 +182,21 @@ 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, 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 + cite a specific TS diagnostic code (TS2344, TS2411, TS2425, TS2509, TS2515, + TS2526, TS4020, TS4111) or a measured library behaviour. The four around roots + and unions: a base constructor may not return a union or a `never`-collapsed intersection (**TS2509** — `SoleType`, and `RootInstance` widening `_tag` to `string`), a mapped behaviour type turns a method into a property and breaks a variant's `override` (**TS2425** — `BehaviourOf`, which must stay unmapped), and abstractness **does** propagate through the intersection (**TS2515**), which is why a root's `abstract` member binds every variant and why `Plain` - strips it back off for the union. Verify before "simplifying" them away — the - catalog in `pnpm-workspace.yaml` pins `typescript` and `@orpc/zod` to the - exact versions those measurements were taken against, with the reason inline. + strips it back off for the union. The accumulating `extend` options add a + fifth: `EntityStatic`'s `G`/`I` are key **unions**, not tuples, because + `readonly [...I, ...I2]` is rejected with **TS2344** — TypeScript will not + prove the parent's key set is a subset of the child's through zod's inference + chain. 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 `tsc --noEmit -p tsconfig.test-d.json`. They are excluded from the main tsc pass, from oxlint, and from knip. Changing a compile-time guarantee (the diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 1e74ffc..b60cb09 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -97,6 +97,12 @@ export abstract class BillingDocumentBase extends Entity.abstract( { generated: ["issuedAt"], immutable: ["issuedAt", "issuedTo"], + computed: { + period: Entity.computed( + AccountingPeriod, + (d) => d.issuedAt.slice(0, 7) as z.infer, + ), + }, invariants: [ Entity.invariant( (d) => d.total.amount >= 0, @@ -117,8 +123,8 @@ export abstract class BillingDocumentBase extends Entity.abstract( export class Invoice extends BillingDocumentBase.extend("Invoice")( { id: InvoiceId, kind: z.literal("INVOICE") /* … */ }, { - generated: ["id", "issuedAt", "kind"], - immutable: ["id", "issuedAt", "issuedTo", "kind"], + generated: ["id", "kind"], + immutable: ["id", "kind"], /* … invariants, one of them */ }, ) { @@ -135,13 +141,18 @@ 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"). +Note what the variants do **not** state. Every option accumulates, +root-then-variant, so `Invoice` names only the keys it introduces: `issuedAt` is +generated and `issuedAt`/`issuedTo` immutable because the root said so, and the +variant adding `id` and `kind` does not disturb that. `computed` accumulates too, +merging per key rather than concatenating: `period` — the accounting period, +derived from `issuedAt`, because reports work per period and a stored copy could +disagree with the date — is on every variant without either of them naming it. +`invariants` work the same way: the root's "total must not be negative" applies +to both variants whether or not they declare rules of their own, and `Invoice` +declares one of its own ("a void invoice cannot be in dunning"). The spec pins +the inheritance both ways — patching `issuedAt` on an invoice is refused, and +`invoice.period` is derived, though `Invoice` mentions neither. ## Nesting, and the factory diff --git a/docs/how-to/evolve-an-entity.md b/docs/how-to/evolve-an-entity.md index 0c0bd94..3700da5 100644 --- a/docs/how-to/evolve-an-entity.md +++ b/docs/how-to/evolve-an-entity.md @@ -140,10 +140,11 @@ 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. +Options declared on the root are inherited, and a variant **adds** to them: name +only the keys and rules the variant itself introduces, and the root's still +apply. Nothing a root declared can be shed — `immutable: []` on a variant does +not widen its `updateInput`. +([How each option merges](/reference/declaration#root-extend-tag-fields-options).) ## Computed fields heal themselves diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index 3cc85ce..285788e 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -257,19 +257,46 @@ class Personal extends AccountBase.extend("Personal")({ } ``` -Options merge per key, child winning — **except `invariants`**, which -concatenates root-then-variant. A variant can add rules; it cannot shed them, so -it is never quietly laxer than its root. Declaring `invariants: []` on a variant -does not clear the root's. - -Every other option — `generated`, `immutable` **and `computed`** — **replaces** -the root's for that key. A variant declaring one of them re-states every entry -it needs, including the root's; one that declares none inherits all three whole. - -`computed` is the one worth watching, because what it drops is a column rather -than a rule: a variant that declares a derived field of its own loses every -derived field the root declared, and the loss is only visible in -`Variant.output.shape`. +Options **accumulate**, root-then-variant. A variant adds to what it inherits +and cannot shed it, so it is never quietly laxer than its root. + +| Option | How a variant's declaration meets the root's | +| ------------ | ------------------------------------------------------------- | +| `generated` | concatenated, root-then-variant | +| `immutable` | concatenated, root-then-variant | +| `invariants` | concatenated, root-then-variant | +| `computed` | merged **per key** — a repeated key takes the variant's entry | + +A variant names only what it adds. `Personal` above declares no options and +inherits everything `AccountBase` declared; a variant declaring +`immutable: ["kind"]` is immutable in `kind` **and** in every key the root +listed. + +Relaxing is not expressible. `immutable: []` on a variant does not widen +`updateInput`, and `invariants: []` does not clear the root's rules — an empty +list contributes nothing, which is not the same as taking something away. + +The key lists are not deduplicated, and do not need to be. Each is turned into a +keyed lookup before it reaches a schema or a patch check, so naming a key the +root already declared is harmless. + +`computed` merges per key rather than concatenating, because it is a map. A +variant can add a derived field beside the root's, and can **redefine** one the +root declared — its schema and its derivation replace that entry alone — but +cannot drop one. + +Redefining an inherited computed key has one edge, measured. The variant's +derivation is what runs, and every surface read off the declaration agrees with +it: `Variant.output.shape`, `toJSON()` and `Entity.Output` all +carry the variant's schema. The **instance property** does not — it keeps the +root's type intersected in, so a key the root branded `Upper` and the variant +rebranded `Label` reads as `Upper & Label` on an instance, and is still +assignable where the root's brand is expected. The root's instance type is +intersected into every variant **unmapped**, and subtracting a key from it is +exactly what `TS2425` forbids: any mapped form turns the root's methods into +function-typed properties and breaks every variant implementing an `abstract` +member. There is no fix pending; read the field off +`Entity.Output` where its exact type matters. `extend` lives only on a root. The entity it returns is final. diff --git a/docs/reference/types.md b/docs/reference/types.md index 368981c..49dba7a 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -47,13 +47,14 @@ surrounding declaration. ## The declaration-emit 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. +Eight types are exported at the top level: `AbstractEntity`, `BaseInstance`, +`ConstructionKey`, `EntityStatic`, `EntityUnion`, `MergedComputed`, `Sealed`, +`UnionMember`. They are the one exception to the single-import rule, and none of +them is part of the API you write against. Seven also have namespace aliases for +anyone annotating by hand — `Entity.Abstract`, `Entity.BaseInstance`, +`Entity.ConstructionKey`, `Entity.MergedComputed`, `Entity.Sealed`, +`Entity.Static`, `Entity.Union` — but a consumer's _emitted declarations_ use the +top-level names. ```ts import type { @@ -62,6 +63,7 @@ import type { ConstructionKey, EntityStatic, EntityUnion, + MergedComputed, Sealed, UnionMember, } from "@btravstack/entity"; @@ -86,6 +88,15 @@ top-level name. What each one buys was measured, not assumed: writing `abstract class X extends Entity.abstract("X")(…) {}` emits the underlying name into its declarations, not the `Entity.Abstract` path that aliases it. +- **`MergedComputed`** — a root's computed map merged with a variant's, which + `extend` hands `EntityStatic` as its `A`. Written inline as + `Omit & A2`, TypeScript 5.9.3 copied the type parameter `A2` + through unsubstituted whenever the root declared no `computed` — the default + — so consumers' declarations carried a name that resolved to nothing and + failed with `TS2304: Cannot find name 'A2'`. 7.0.2 substitutes the same + position correctly, so only downstream builds saw it. Naming it is half the + fix and exporting it is the other half: unexported, the emitter expands the + alias structurally again and the identical dangling `A2` comes back. - **`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 @@ -93,6 +104,9 @@ top-level name. What each one buys was measured, not assumed: `EntityUnion` because it is that type's own constraint. A fixture in CI compiles a consumer with declaration emit against the built -types, so none of this can regress. See +types, on two TypeScript versions, and then **type-checks what it emitted** — +which is not the same guarantee: `MergedComputed` above was found only because +that last step exists, since a dangling reference in the output is no emit-time +diagnostic. See [Sealed construction](/explanation/sealed-construction) for what the seal buys and what the two rejected alternatives cost. diff --git a/docs/typedoc.json b/docs/typedoc.json index 2ac4310..c45a87b 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -29,6 +29,7 @@ "Invariant", "InvariantSrc", "IsNominalField", + "MergedComputedSrc", "OnlyNominal", "OutputOf", "PatchOf", diff --git a/examples/billing-domain/package.json b/examples/billing-domain/package.json index 9e3c160..a7c423f 100644 --- a/examples/billing-domain/package.json +++ b/examples/billing-domain/package.json @@ -11,7 +11,7 @@ }, "scripts": { "test": "vitest run", - "typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json" + "typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 node_modules/.emit-check/index.d.ts node_modules/.emit-check/emit-guards.d.ts node_modules/.emit-check/index.spec.d.ts" }, "dependencies": { "@btravstack/entity": "workspace:*", diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index 49cf7fa..27dec29 100644 --- a/examples/billing-domain/src/emit-guards.ts +++ b/examples/billing-domain/src/emit-guards.ts @@ -6,7 +6,7 @@ * library **and emits its own declarations**. It replaced * `packages/entity/consumer/`. * - * Two rules that are easy to destroy by tidying: + * Three rules that are easy to destroy by tidying: * * 1. **An unused `@ts-expect-error` here is a failure, not noise.** A * namespace member emitted as a circular self-alias still *compiles*; the @@ -14,7 +14,22 @@ * signal. Every member of `Entity` is therefore named below, so * declaration emit has to walk each one. * - * 2. **The widths in `vocabulary.ts` are load bearing.** `TS7056` is a + * 2. **The gate checks the emitted declarations, not only that emitting + * succeeded.** `typecheck`'s last step feeds the emitted + * `node_modules/.emit-check` back through the 5.9.3 compiler. Without it the + * pass caught only emit-time diagnostics (`TS4020` and friends); a *dangling + * type-parameter reference in the output* is not one, and one shipped — + * `Omit & A2` written inline at `extend`'s return type emitted + * a bare `A2`, which failed a consumer with `TS2304`. The step names + * `index.d.ts`, `emit-guards.d.ts` and `index.spec.d.ts` rather than + * `index.d.ts` alone: a file is checked only if it is in the named set or + * something in it imports the file, and **nothing imports this one**, so on + * `index.d.ts` alone the emitted form of every namespace member below went + * unchecked. `organization`/`root`/`vocabulary` need no naming — `index` + * imports them. Never add `--skipLibCheck`: it turns off `.d.ts` checking + * entirely and the run exits 0 on the broken output. Both measured. + * + * 3. **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 @@ -68,13 +83,14 @@ export type OrgPatch = Entity.Patch; export type Derived = Entity.ComputedField }>; export type Rule = Entity.Invariant<{ slug: z.infer }>; export type SealedRow = Entity.Sealed; -export type Base = Entity.BaseInstance<{ slug: typeof Slug }, Record, []>; +export type Base = Entity.BaseInstance<{ slug: typeof Slug }, Record, never>; +// `G` and `I` are unions of keys, so the empty case is `never` rather than `[]`. export type Static = Entity.Static< "Organization", { slug: typeof Slug }, Record, - [], - [] + never, + never >; export type Members = Entity.Union<"kind", [typeof Invoice, typeof CreditNote]>; export type AnyDocument = Entity.Instance; @@ -83,9 +99,10 @@ export type Root = Entity.Abstract< "BillingDocument", { total: typeof Money }, Record, - [], - [] + never, + never >; +export type Merged = Entity.MergedComputed<{ label: typeof DisplayLabel }, 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 6312a9c..2c3bf4a 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -2,6 +2,7 @@ import { P } from "unthrown"; import { expect, test } from "vitest"; import { + AccountingPeriod, BillingDocument, CreditNote, DisplayName, @@ -114,6 +115,34 @@ test("each variant signs the shared amount its own way", () => { expect(note.counterpartySlug).toBe("acme"); }); +test("a variant inherits the root's computed field without re-stating it", () => { + const drafted = invoice(); + expect(drafted.period).toBe(drafted.issuedAt.slice(0, 7)); + + // Both variants, since neither names `period` and the claim is that every one + // of them gets it — an assertion on `Invoice` alone would not say that. + const note = createCreditNote({ + issuedTo: org(), + against: InvoiceId.parse("33333333-3333-4333-8333-333333333333"), + total: money(500, "EUR"), + }).getOrThrow(); + expect(note.period).toBe(note.issuedAt.slice(0, 7)); + + // Derived, so it is not patchable on either. + expect(Object.keys(Invoice.updateInput.shape)).not.toContain("period"); + expect(Object.keys(CreditNote.updateInput.shape)).not.toContain("period"); +}); + +test("the period's own schema rejects a month that cannot exist", () => { + // A computed field's schema is what makes `from`'s unchecked cast honest, so + // it has to be able to fail. `\d{2}` would pass all three of these. + expect(AccountingPeriod.safeParse("2026-03").success).toBe(true); + expect(AccountingPeriod.safeParse("2026-12").success).toBe(true); + for (const impossible of ["2026-00", "2026-13", "2026-99"]) { + expect(AccountingPeriod.safeParse(impossible).success).toBe(false); + } +}); + 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. @@ -174,3 +203,16 @@ test("an unknown discriminant is a reported error, not a silent miss", async () expect(message).toContain('"INVOICE"'); expect(message).toContain('"CREDIT_NOTE"'); }); + +test("a variant inherits the root's immutable keys without re-stating them", () => { + const drafted = invoice(); + // `issuedAt` is immutable, so `PatchOf` omits it — smuggle it in like crud.spec.ts does. + const rejected = drafted.update({ issuedAt: drafted.issuedAt } as never); + + const message = rejected.match({ + ok: () => "WRONGLY ACCEPTED", + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues[0]?.message ?? ""), + defect: () => "defect", + }); + expect(message).toBe("Immutable field — cannot be patched"); +}); diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index 89c6466..fdc3de7 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -45,11 +45,8 @@ export class Invoice extends BillingDocumentBase.extend("Invoice")( level: Level, }, { - // `generated` and `immutable` **replace** the root's list per key rather - // than adding to it, so a variant re-states every key it needs — - // `issuedAt` and `issuedTo` here. Only `invariants` concatenate. - generated: ["id", "issuedAt", "kind"], - immutable: ["id", "issuedAt", "issuedTo", "kind"], + generated: ["id", "kind"], + immutable: ["id", "kind"], invariants: [ Entity.invariant( (d) => d.status !== "VOID" || d.dunningReasons.length === 0, @@ -76,8 +73,8 @@ export class Invoice extends BillingDocumentBase.extend("Invoice")( 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"], + generated: ["id", "kind"], + immutable: ["id", "against", "kind"], }, ) { override signedAmount(): number { diff --git a/examples/billing-domain/src/root.ts b/examples/billing-domain/src/root.ts index 7c9d89b..79f0320 100644 --- a/examples/billing-domain/src/root.ts +++ b/examples/billing-domain/src/root.ts @@ -1,7 +1,8 @@ import { Entity } from "@btravstack/entity"; +import type { z } from "zod"; import { Organization } from "./organization.js"; -import { Instant, Money } from "./vocabulary.js"; +import { AccountingPeriod, Instant, Money } from "./vocabulary.js"; /** * What every billing document shares. A root rather than a third entity: it is @@ -23,6 +24,15 @@ import { Instant, Money } from "./vocabulary.js"; * 7.0.2 and 5.9.3: both paths emit clean, and `update`'s polymorphic `this` * survives both as `Result` rather than degrading. * + * **The `computed` block is load bearing too, and not only as illustration.** + * `extend`'s third type argument is `MergedComputed`, so a root that + * declares nothing only ever exercises it at `A = Record` — the + * cheap branch, where the merge costs nothing whatever a variant declares. With + * a real map the root's every computed schema serialises into every variant's + * `.d.ts`, which is what spends the `TS7056` budget, and it grows with the + * root. Measured with `period` in place: both TypeScript 7.0.2 and 5.9.3 emit + * clean, and the emitted output type-checks on 5.9.3. + * * 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. @@ -32,6 +42,12 @@ export abstract class BillingDocumentBase extends Entity.abstract("BillingDocume { generated: ["issuedAt"], immutable: ["issuedAt", "issuedTo"], + computed: { + period: Entity.computed( + AccountingPeriod, + (d) => d.issuedAt.slice(0, 7) as z.infer, + ), + }, invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], }, ) { diff --git a/examples/billing-domain/src/vocabulary.ts b/examples/billing-domain/src/vocabulary.ts index 9786e53..4cb3986 100644 --- a/examples/billing-domain/src/vocabulary.ts +++ b/examples/billing-domain/src/vocabulary.ts @@ -16,6 +16,21 @@ 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"); +/** + * The accounting period a document falls in — `2026-03`. Billing reports and + * revenue recognition both work per period, so it is derived from the issue + * date rather than stored beside it, where the two could disagree. + * + * The month alternation is not decoration. A derived field's schema is what + * makes `from`'s unchecked `as` cast honest — see `computed.ts` — so a laxer + * `\d{2}` would bless `2026-00` and `2026-99` on the way out of a derivation + * that is supposed to have proved them impossible. + */ +export const AccountingPeriod = z + .string() + .regex(/^\d{4}-(0[1-9]|1[0-2])$/) + .brand("AccountingPeriod"); + export const Currency = z.enum(["EUR", "USD", "GBP"]); /** diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index 5b6fcb8..2236354 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -166,16 +166,17 @@ test("a variant's own schemas include both halves", () => { expect(Object.keys(Personal.output.shape).toSorted()).toEqual(["id", "kind", "label", "shout"]); }); -test("a variant option overrides the root's for that key", () => { +test("a variant's option adds to the root's, it does not replace", () => { 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"]); + // the root declared `id` immutable; declaring an empty list cannot shed it + expect(Object.keys(Loose.updateInput.shape).toSorted()).toEqual(["label", "note"]); }); -test("a variant declaring computed replaces the root's, it does not add to them", () => { +test("a variant declaring computed keeps the root's", () => { class Quiet extends AccountBase.extend("Quiet")( { note: Label }, { @@ -188,13 +189,17 @@ test("a variant declaring computed replaces the root's, it does not add to them" 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"]); + // `computed` is a map, so it merges per key — the root's `shout` survives + expect(Object.keys(Quiet.output.shape).toSorted()).toEqual([ + "id", + "label", + "murmur", + "note", + "shout", + ]); }); -test("invariants are the exception: a variant adds to the root's, never replaces", () => { +test("a variant adds to the root's invariants, never replaces", () => { const Score = z.number().int().brand("Score"); class Stricter extends AccountBase.extend("Stricter")( { score: Score }, @@ -255,3 +260,44 @@ test("a variant is still sealed and still refuses a bare subclass", () => { }); expect(outcome).toBe("defect"); }); + +test("immutable accumulates through a behaviour-only intermediate root", () => { + // Entities are final, so options never chain root → variant → variant. The + // only multi-level shape is root → intermediate root → variant, and + // `Auditable` is already declared above as exactly that. + class Audited extends Auditable.extend("Audited")({ note: Label }, { immutable: ["note"] }) { + override describe(): string { + return "audited"; + } + } + // `id` from AccountBase, `note` from here — `label` is all that is left + expect(Object.keys(Audited.updateInput.shape).toSorted()).toEqual(["label"]); +}); + +test("generated accumulates, so a variant cannot make a root's key caller-supplied", () => { + abstract class Stamped extends Entity.abstract("Stamped")( + { id: AccountId, at: Label }, + { generated: ["at"] }, + ) {} + class Doc extends Stamped.extend("Doc")({ note: Label }, { generated: ["id"] }) {} + // `at` is the root's, `id` is the variant's — `createInput` keeps neither + expect(Object.keys(Doc.createInput.shape).toSorted()).toEqual(["note"]); +}); + +test("a variant redefining one computed key overrides that entry only", () => { + class Louder extends AccountBase.extend("Louder")( + { note: Label }, + { + computed: { + shout: Entity.computed(Upper, (d) => `${d.label}!`.toUpperCase() as z.infer), + }, + }, + ) { + override describe(): string { + return "louder"; + } + } + const l = Louder.make({ id, label: "Ada", note: "n" }).getOrThrow(); + expect(l.shout).toBe("ADA!"); + expect(Object.keys(Louder.output.shape).toSorted()).toEqual(["id", "label", "note", "shout"]); +}); diff --git a/packages/entity/src/base.test-d.ts b/packages/entity/src/base.test-d.ts index 8bb8a81..ce8603f 100644 --- a/packages/entity/src/base.test-d.ts +++ b/packages/entity/src/base.test-d.ts @@ -6,10 +6,16 @@ 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"] }, + { + immutable: ["id"], + computed: { + shout: Entity.computed(Upper, (d) => d.label.toUpperCase() as z.infer), + }, + }, ) { abstract describe(): string; get slug(): string { @@ -94,4 +100,56 @@ test("a root enforces the same field rules as a fresh declaration", () => { AccountBase.extend("Reserved")({ update: Label }); }); +test("a variant cannot shed the root's immutable keys", () => { + class Noted extends AccountBase.extend("Noted")({ note: Label }, { immutable: ["note"] }) { + override describe(): string { + return "noted"; + } + } + const n = Noted.make({}).getOrThrow(); + n.update({ label: "x" as z.infer }); + // @ts-expect-error `id` is the root's immutable, and declaring our own cannot shed it + n.update({ id: n.id }); + // @ts-expect-error `note` is the variant's own immutable + n.update({ note: n.note }); +}); + +test("a redefined computed key takes the variant's type, not an intersection", () => { + class Louder extends AccountBase.extend("Louder")( + { note: Label }, + { + computed: { + shout: Entity.computed(Label, (d) => d.label.toLowerCase() as z.infer), + }, + }, + ) { + override describe(): string { + return "louder"; + } + } + // Read off `Entity.Output`, which is `OutputOf` alone. An *instance* is + // that intersected with `BehaviourOf`, and a root's instance type + // carries the root's data as well as its behaviour — so `l.shout` is measured + // as `Label & Upper` whatever the computed merge says, and subtracting the + // data is what TS2425 forbids (see `BehaviourOf` in `types.ts`). Every surface + // reading `A` on its own — `__output`, `toJSON()`, `output.shape` — is clean. + type Out = Entity.Output; + // the root typed `shout` as Upper; the variant retypes it as Label. Under a + // plain `A & A2` this would be `Upper & Label` and neither line would compile. + const asLabel: z.infer = null as unknown as Out["shout"]; + void asLabel; + // @ts-expect-error the root's `Upper` brand is gone, not intersected in + const asUpper: z.infer = null as unknown as Out["shout"]; + void asUpper; + + // The instance-level residue the comment above describes, asserted rather + // than only narrated: `l.shout` satisfies *both* brands, so the day the + // intersection stops surviving `BehaviourOf`, this stops compiling. + const l = null as unknown as Entity.Instance; + const instanceAsLabel: z.infer = l.shout; + const instanceAsUpper: z.infer = l.shout; + void instanceAsLabel; + void instanceAsUpper; +}); + void Business; diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts index b4c92a8..c443448 100644 --- a/packages/entity/src/base.ts +++ b/packages/entity/src/base.ts @@ -43,12 +43,30 @@ const declarationOf = (receiver: object) => { return undefined; }; +/** The options `rebuild` merges rather than overwrites. */ +type Mergeable = { + readonly generated?: readonly PropertyKey[]; + readonly immutable?: readonly PropertyKey[]; + readonly computed?: Record; + readonly invariants?: readonly Invariant[]; +}; + +const concat = (parent: readonly T[] | undefined, child: readonly T[] | undefined): T[] => [ + ...(parent ?? []), + ...(child ?? []), +]; + /** - * 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. + * Every option accumulates parent-then-child; nothing is shed. An extension can + * add rules, keys and derived fields; it cannot drop the ones it inherits. + * + * `computed` merges per key rather than concatenating, because it is a map: + * a variant adding `murmur` keeps the root's `shout`, and one redefining + * `shout` overrides that entry alone. + * + * Lists are not deduplicated. A repeated key is harmless — `maskOf` builds an + * object from them, so duplicates collapse — and leaving it out keeps the merge + * one expression. */ const rebuild = ( buildEntity: BuildEntity, @@ -58,19 +76,23 @@ const rebuild = ( 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 ?? [])]; + const parentOptions = parent?.options as Mergeable | undefined; + const childOptions = nextOptions as Mergeable | undefined; + + const generated = concat(parentOptions?.generated, childOptions?.generated); + const immutable = concat(parentOptions?.immutable, childOptions?.immutable); + const invariants = concat(parentOptions?.invariants, childOptions?.invariants); + const computed = { ...parentOptions?.computed, ...childOptions?.computed }; + return buildEntity(nextTag)( { ...parent?.fields, ...nextFields }, { ...parent?.options, ...nextOptions, + ...(generated.length > 0 ? { generated } : {}), + ...(immutable.length > 0 ? { immutable } : {}), ...(invariants.length > 0 ? { invariants } : {}), + ...(Object.keys(computed).length > 0 ? { computed } : {}), }, ); }; @@ -121,7 +143,7 @@ export const createBase = readonly computed?: { [K in keyof A]: ComputedField> }; readonly invariants?: readonly Invariant>[]; }, - ): AbstractEntity => { + ): AbstractEntity => { class Root { static readonly entityName = name; constructor() { @@ -135,5 +157,5 @@ export const createBase = } record(Root, fields as Fields, options as Record | undefined); defineRootExtend(Root, buildEntity); - return Root as unknown as AbstractEntity; + return Root as unknown as AbstractEntity; }; diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index ea79a86..262e3a3 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -25,6 +25,7 @@ import type { InputOf, EntityStatic, Fields, + MergedComputed, PatchOf, Sealed, UpdateInputShapeOf, @@ -70,7 +71,7 @@ export function Entity(tag: Tag) { // field — see `invariant.ts` for why that is both sound and necessary. readonly invariants?: readonly Invariant>[]; }, - ): EntityStatic { + ): EntityStatic { const input = shape(fields); // `.omit()`'s mask can't be satisfied by a mask built from a generic key @@ -122,7 +123,7 @@ export function Entity(tag: Tag) { >; /** what a caller may send to update */ const updateInput = omitBy(output as z.ZodObject, frozenKeys).partial() as z.ZodObject< - UpdateInputShapeOf + UpdateInputShapeOf >; type OutputShape = OutputOf; @@ -382,8 +383,8 @@ export function Entity(tag: Tag) { /** caller fields + domain-generated fields → entity */ static factory( this: new (d: Sealed) => T, - generators: Generators, - ): EntityFactory { + generators: Generators, + ): EntityFactory { const Ctor = this as unknown as { make: (state: unknown) => Result }; // generated spreads last, so a caller cannot override a domain-owned field return (input) => Ctor.make({ ...(input as object), ...callAll(generators) }); @@ -391,8 +392,8 @@ export function Entity(tag: Tag) { static factoryAsync( this: new (d: Sealed) => T, - generators: AsyncGenerators, - ): AsyncEntityFactory { + generators: AsyncGenerators, + ): AsyncEntityFactory { const Ctor = this as unknown as { make: (state: unknown) => Result }; // a generator that rejects is infrastructure failing, not bad domain // input, so it stays a Defect rather than becoming an InvalidEntity @@ -403,7 +404,7 @@ export function Entity(tag: Tag) { } /** a partial of the mutable fields → a NEW entity */ - update(this: Base, patch: PatchOf): Result { + update(this: Base, patch: PatchOf): Result { const entries = Object.entries(patch as object); // Every offending key reports, not just the first — the same rule the // invariants follow. `path` carries the key, so an adapter can key a @@ -427,7 +428,7 @@ export function Entity(tag: Tag) { attachSchema>(Base, input); record(Base, fields, options as Record | undefined); - return Base as unknown as EntityStatic; + return Base as unknown as EntityStatic; }; } @@ -469,24 +470,25 @@ type InvariantSrc = Invariant; type EntityUnionSrc = EntityUnion; type ConstructionKeySrc = ConstructionKey; type SealedSrc = Sealed; -type BaseInstanceSrc< - S extends Fields, - A extends Fields, - I extends readonly (keyof OutputOf)[], -> = BaseInstance; +type BaseInstanceSrc = BaseInstance< + S, + A, + I +>; type AbstractEntitySrc< Name extends string, S extends Fields, A extends Fields, - G extends readonly (keyof S)[], - I extends readonly (keyof OutputOf)[], + G extends PropertyKey, + I extends PropertyKey, > = AbstractEntity; +type MergedComputedSrc = MergedComputed; type EntityStaticSrc< Tag extends string, S extends Fields, A extends Fields, - G extends readonly (keyof S)[], - I extends readonly (keyof OutputOf)[], + G extends PropertyKey, + I extends PropertyKey, > = EntityStatic; export declare namespace Entity { @@ -518,19 +520,21 @@ export declare namespace Entity { export type Union = EntityUnionSrc; // Exported only so a consumer's emitted declarations can name them — none of - // the three is part of the API you write against. See `Sealed` in types.ts. + // the four is part of the API you write against. See `Sealed` in types.ts. export type BaseInstance< S extends Fields, A extends Fields, - I extends readonly (keyof OutputOf)[], + I extends PropertyKey, > = BaseInstanceSrc; export type ConstructionKey = ConstructionKeySrc; export type Sealed = SealedSrc; + /** A root's computed map merged with a variant's — what `extend` hands `Static` as its `A`. */ + export type MergedComputed = MergedComputedSrc; /** * What `Entity(tag)(fields, options)` returns — the static surface itself. * - * Exported for the same reason as the three above: a consumer's emitted + * Exported for the same reason as the four above: a consumer's emitted * declarations have to name it, and the cost of them not being able to was * two build failures rather than a verbose `.d.ts`. See `index.ts`. */ @@ -538,8 +542,8 @@ export declare namespace Entity { Tag extends string, S extends Fields, A extends Fields, - G extends readonly (keyof S)[], - I extends readonly (keyof OutputOf)[], + G extends PropertyKey, + I extends PropertyKey, > = EntityStaticSrc; /** What `Entity.abstract(name)(fields, options)` returns. */ @@ -547,8 +551,8 @@ export declare namespace Entity { Name extends string, S extends Fields, A extends Fields, - G extends readonly (keyof S)[], - I extends readonly (keyof OutputOf)[], + G extends PropertyKey, + I extends PropertyKey, > = AbstractEntitySrc; /** diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index ddb5324..bb4c990 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -22,7 +22,24 @@ export { Entity } from "./entity.js"; // `DeepReadonly` until zod's module-private `$brand` symbol reached // computed-key position and could not be named (`TS4020`, #32). Emitting // `EntityStatic<…>` by reference fixes both. Do not un-export it. -export type { BaseInstance, ConstructionKey, EntityStatic, Sealed } from "./types.js"; +// +// `MergedComputed` joins them for a narrower reason, measured the same way: it +// is the third type argument `extend` hands to `EntityStatic`, and written +// inline as `Omit & A2` the 5.9.3 emitter copied the type parameter +// `A2` through unsubstituted, leaving `TS2304: Cannot find name 'A2'` in the +// consumer's own `.d.ts`. 7.0.2 substitutes the same position correctly, so only +// downstream builds saw it. **Both halves are load bearing, and the export is +// not the cosmetic half.** Measured by unexporting it and re-running the fixture +// against a root declaring no `computed`: the emitter expands the alias +// structurally again and the identical dangling `A2` comes back, `TS2304` and +// all. Naming it without exporting it fixes nothing. Do not un-export it. +export type { + BaseInstance, + ConstructionKey, + EntityStatic, + MergedComputed, + Sealed, +} from "./types.js"; // Same story as `EntityStatic`: a consumer writing // `abstract class X extends Entity.abstract("X")(…) {}` emits the *underlying* diff --git a/packages/entity/src/types.test-d.ts b/packages/entity/src/types.test-d.ts index 7160d9a..4bc827b 100644 --- a/packages/entity/src/types.test-d.ts +++ b/packages/entity/src/types.test-d.ts @@ -57,17 +57,17 @@ test("OutputOf with no computed fields is the encoded object", () => { }); test("CreateInputOf drops the generated fields, GeneratedOf keeps exactly them", () => { - type C = CreateInputOf; + type C = CreateInputOf; expectTypeOf().not.toHaveProperty("id"); expectTypeOf().toEqualTypeOf>(); - type G = GeneratedOf; + type G = GeneratedOf; expectTypeOf().toEqualTypeOf>(); expectTypeOf().not.toHaveProperty("slug"); }); test("PatchOf is partial and drops the immutable fields", () => { - type P = PatchOf; + type P = PatchOf; expectTypeOf

().not.toHaveProperty("id"); expectTypeOf().toEqualTypeOf | undefined>(); // `fingerprint` is not in the immutable list, yet it is still gone: a diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index fa9d388..c878aad 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -45,22 +45,23 @@ export type ComputedOf = [keyof A] extends [never] export type OutputOf = InputOf & ComputedOf; /** What `create` accepts from a caller: everything the domain does not generate. */ -export type CreateInputOf = Omit< - InputOf, - G[number] ->; +export type CreateInputOf = Omit, G>; /** * What `create` requires the use case to supply. * * `Pick` constrains its second parameter to `keyof T`, and TypeScript cannot - * prove `G[number]` — which is `keyof S` — satisfies `keyof InputOf` - * through zod's inference chain. This mapped type with key remapping achieves + * prove `G` satisfies `keyof InputOf` through zod's inference chain — which + * is also why `G`'s bound here is the bare `PropertyKey` rather than `keyof S`. + * The builders are what constrain the real call sites to `S`'s keys; this type + * only has to survive them. This mapped type with key remapping achieves * the same semantics. `CreateInputOf` uses `Omit` (no such constraint); - * `GeneratedOf` uses this mapped form for that reason. + * `GeneratedOf` uses this mapped form for that reason. The same unprovable + * subset relation is why `G` is a key union and not a tuple: the accumulating + * `readonly [...G, ...G2]` spelling is rejected with `TS2344`. */ -export type GeneratedOf = { - [K in keyof InputOf as K extends G[number] ? K : never]: InputOf[K]; +export type GeneratedOf = { + [K in keyof InputOf as K extends G ? K : never]: InputOf[K]; }; /** @@ -137,11 +138,9 @@ export type DeepReadonly = T extends Immutable * supplied. `update` re-runs every derivation like any other construction * path, so a patched value would only be overwritten by the next one. */ -export type PatchOf< - S extends Fields, - A extends Fields, - I extends readonly (keyof OutputOf)[], -> = Partial, I[number] | keyof A>>; +export type PatchOf = Partial< + Omit, I | keyof A> +>; /** * The field *schemas* `updateInput` is built from: the output field map @@ -153,12 +152,8 @@ export type PatchOf< * signature, so `Organization.updateInput.shape.name` is a named property * access, not one this repo's `noPropertyAccessFromIndexSignature` rejects. */ -export type UpdateInputShapeOf< - S extends Fields, - A extends Fields, - I extends readonly (keyof OutputOf)[], -> = { - [Key in Exclude]: z.ZodOptional<(S & A)[Key]>; +export type UpdateInputShapeOf = { + [Key in Exclude]: z.ZodOptional<(S & A)[Key]>; }; /** @@ -209,11 +204,7 @@ export type Sealed = D & { readonly __useMakeOrFactoryInstead: ConstructionKe // converting this one to a `type` reintroduces exactly the TS2526 this // package's other `interface`-avoidance already worked around elsewhere. // oxlint-disable-next-line typescript/consistent-type-definitions -export interface BaseInstance< - S extends Fields, - A extends Fields, - I extends readonly (keyof OutputOf)[], -> { +export interface BaseInstance { toJSON(): DeepReadonly>; equals(other: unknown): boolean; update(patch: PatchOf): Result; @@ -239,7 +230,7 @@ type ConstructedInstance< Tag extends string, S extends Fields, A extends Fields, - I extends readonly (keyof OutputOf)[], + I extends PropertyKey, > = BaseInstance & DeepReadonly> & { readonly _tag: Tag; @@ -255,11 +246,12 @@ type ConstructedInstance< * (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 }; +export type RootInstance = BaseInstance< + S, + A, + I +> & + DeepReadonly> & { readonly _tag: string }; /** * Whatever the receiver's own class body added, carried **unmapped**. @@ -276,6 +268,33 @@ export type BehaviourOf = This extends abstract new (...args: never[]) => ? R : Record; +/** + * A root's computed map merged with a variant's — what `extend` hands + * `EntityStatic` as its `A`. + * + * `Omit & A2`, never `A & A2`: the runtime spread lets a variant's + * computed key win, and a plain intersection would type a redefined key as + * `Upper & Lower` while the value is `Lower`. + * + * Named rather than written inline at `extend`'s return type, which was measured + * to emit a dangling reference: with `A = Record` — a root + * declaring no `computed`, which is the default — TypeScript 5.9.3 wrote the + * *unsubstituted* `Omit, keyof A2> & Record` + * into the consumer's `.d.ts`, where the consumer's own compiler rejected it + * with `TS2304: Cannot find name 'A2'`. TypeScript 7.0.2 substitutes the same + * position correctly. Naming it is only half the fix: the emitter writes the + * *name* only because `index.ts` exports it, and unexporting it was measured to + * expand the alias structurally and bring the identical dangling `A2` straight + * back. See the export list there, and do not un-export it. + * + * The *fields* half of the same merge is `S & S2`, not this shape, and carries + * the same lie: the runtime spread is child-wins there too, so a redefined field + * types as `Parent & Child`. Left as is deliberately — the `Omit` form costs + * serialised characters against the `TS7056` budget on **every** entity, where + * this one is only paid by an entity that declares `computed`. + */ +export type MergedComputed = Omit & A2; + /** * What `Entity.abstract(name)(fields, options?)` returns. * @@ -288,8 +307,8 @@ export type AbstractEntity< Name extends string, S extends Fields, A extends Fields, - G extends readonly (keyof S)[], - I extends readonly (keyof OutputOf)[], + G extends PropertyKey, + I extends PropertyKey, > = { new (d: Sealed>): RootInstance; readonly entityName: Name; @@ -309,14 +328,9 @@ export type AbstractEntity< 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 - : [], + A2 extends Fields = Record, + const G2 extends readonly (keyof (S & S2))[] = [], + const I2 extends readonly (keyof OutputOf>)[] = [], >( fields: S2 & OnlyNominal, options?: { @@ -325,7 +339,14 @@ export type AbstractEntity< readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; readonly invariants?: readonly InvariantOf>[]; }, - ) => EntityStatic>; + ) => EntityStatic< + Tag2, + S & S2, + MergedComputed, + G | G2[number], + I | I2[number], + BehaviourOf + >; }; /** @@ -344,8 +365,13 @@ export type EntityStatic< Tag extends string, S extends Fields, A extends Fields, - G extends readonly (keyof S)[], - I extends readonly (keyof OutputOf)[], + // `G`/`I` are unions of keys, not tuples. The tuple form cannot express the + // merge: `readonly [...I, ...I2]` is rejected with `TS2344`, because + // TypeScript will not prove the parent's key set is a subset of the child's + // through zod's inference chain. Measured — see `GeneratedOf` for the same + // failure in its `Pick` form. + G extends PropertyKey, + I extends PropertyKey, // What the abstract root's class body contributed, or nothing. Defaulted so // every existing five-argument spelling keeps compiling. // @@ -364,7 +390,7 @@ export type EntityStatic< readonly entityName: Tag; readonly input: z.ZodObject; readonly output: z.ZodObject; - readonly createInput: z.ZodObject>; + readonly createInput: z.ZodObject>; readonly updateInput: z.ZodObject>; /** * The zod slots that make the class itself a schema, so it composes @@ -410,11 +436,11 @@ export type EntityStatic< * each is called once per `create`, so a factory built at the composition root * yields a fresh id and timestamp every time. */ -export type Generators = { +export type Generators = { [K in keyof GeneratedOf]: () => GeneratedOf[K]; }; -export type AsyncGenerators = { +export type AsyncGenerators = { [K in keyof GeneratedOf]: () => PromiseLike[K]>; }; @@ -425,10 +451,10 @@ export type AsyncGenerators = * consumes generators, so `.create` was ceremony around the only thing a * factory does. `make` stays on the class. */ -export type EntityFactory = ( +export type EntityFactory = ( input: CreateInputOf, ) => Result; -export type AsyncEntityFactory = ( +export type AsyncEntityFactory = ( input: CreateInputOf, ) => AsyncResult;