From 6b89ddca208401b218b90c12cc9de56eb25e753a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 00:38:04 +0200 Subject: [PATCH 1/9] feat!: extend options accumulate instead of replacing --- packages/entity/src/base.spec.ts | 62 ++++++++++++++++--- packages/entity/src/base.test-d.ts | 51 ++++++++++++++- packages/entity/src/base.ts | 50 ++++++++++----- packages/entity/src/entity.ts | 44 ++++++------- packages/entity/src/types.test-d.ts | 6 +- packages/entity/src/types.ts | 96 ++++++++++++++--------------- 6 files changed, 213 insertions(+), 96 deletions(-) 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..fe8efd2 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,47 @@ 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; +}); + 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..e816e8a 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -70,7 +70,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 +122,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 +382,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 +391,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 +403,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 +427,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 +469,24 @@ 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 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 { @@ -522,7 +522,7 @@ export declare namespace Entity { 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; @@ -538,8 +538,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 +547,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/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..1efaeca 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -45,22 +45,21 @@ 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` + * prove `G` — which is `keyof S` — satisfies `keyof InputOf` * through zod's inference chain. 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 +136,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 +150,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 +202,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 +228,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 +244,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**. @@ -288,8 +278,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 +299,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 & A2>)[] = [], >( fields: S2 & OnlyNominal, options?: { @@ -325,7 +310,17 @@ export type AbstractEntity< readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; readonly invariants?: readonly InvariantOf>[]; }, - ) => EntityStatic>; + // `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`. + ) => EntityStatic< + Tag2, + S & S2, + Omit & A2, + G | G2[number], + I | I2[number], + BehaviourOf + >; }; /** @@ -344,8 +339,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 +364,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 +410,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 +425,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; From 597a6dddcf101d54e8dfa454e05003cb4c5ccd96 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 00:51:29 +0200 Subject: [PATCH 2/9] test: let the billing variants inherit the root's keys --- examples/billing-domain/src/emit-guards.ts | 11 ++++++----- examples/billing-domain/src/index.spec.ts | 13 +++++++++++++ examples/billing-domain/src/index.ts | 11 ++++------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index 49cf7fa..f7edb05 100644 --- a/examples/billing-domain/src/emit-guards.ts +++ b/examples/billing-domain/src/emit-guards.ts @@ -68,13 +68,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,8 +84,8 @@ export type Root = Entity.Abstract< "BillingDocument", { total: typeof Money }, Record, - [], - [] + never, + never >; /** The error is reachable as both a value and a type. */ diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index 6312a9c..fcf5ade 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -174,3 +174,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 { From c55486495f6e8b0110d11fb3701ae74b1bbdeac1 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 01:02:07 +0200 Subject: [PATCH 3/9] docs: cover the accumulating extend options --- .changeset/extend-options-accumulate.md | 72 +++++++++++++++++++++++++ CLAUDE.md | 26 +++++---- docs/examples/billing-domain.md | 19 +++---- docs/how-to/evolve-an-entity.md | 9 ++-- docs/reference/declaration.md | 53 +++++++++++++----- 5 files changed, 143 insertions(+), 36 deletions(-) create mode 100644 .changeset/extend-options-accumulate.md diff --git a/.changeset/extend-options-accumulate.md b/.changeset/extend-options-accumulate.md new file mode 100644 index 0000000..46ab503 --- /dev/null +++ b/.changeset/extend-options-accumulate.md @@ -0,0 +1,72 @@ +--- +"@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 +Entity.abstract("BillingDocument")(fields, { + immutable: ["issuedAt", "issuedTo"], +}); +// variant — before this change, the root's two were gone, with no diagnostic +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. See +[Declaring an entity](https://btravstack.github.io/entity/reference/declaration). + +**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. + +`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 three constraints. `Entity.Static`, `Entity.Abstract` +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 all three are named in consumers' emitted declarations, which is why it is +listed here. diff --git a/CLAUDE.md b/CLAUDE.md index 9425c5a..721055a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,10 +107,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 +176,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..1a47062 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -117,8 +117,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 +135,14 @@ 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. `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: patching +`issuedAt` on an invoice is refused, though `Invoice` never mentions it. ## 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. From d7c735ea811057deee2c2cc4afcaaa910c0ae79c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 01:11:00 +0200 Subject: [PATCH 4/9] docs: give the relaxation break a migration path --- .changeset/extend-options-accumulate.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.changeset/extend-options-accumulate.md b/.changeset/extend-options-accumulate.md index 46ab503..3d4f2fb 100644 --- a/.changeset/extend-options-accumulate.md +++ b/.changeset/extend-options-accumulate.md @@ -12,11 +12,14 @@ so this silently made `issuedAt` and `issuedTo` patchable: ```ts // root -Entity.abstract("BillingDocument")(fields, { - immutable: ["issuedAt", "issuedTo"], -}); +abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( + fields, + { immutable: ["issuedAt", "issuedTo"] }, +) {} // variant — before this change, the root's two were gone, with no diagnostic -BillingDocumentBase.extend("Invoice")(fields, { immutable: ["id", "kind"] }); +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 @@ -28,14 +31,18 @@ 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. See -[Declaring an entity](https://btravstack.github.io/entity/reference/declaration). +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. +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`: From 76ef77523b20ce9d11e386f3d8a66038acb21691 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 01:37:03 +0200 Subject: [PATCH 5/9] fix: name the computed merge so a consumer can emit it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extend`'s return type spelled the merge inline as `Omit & A2`. TypeScript 5.9.3 copied the type parameter `A2` through unsubstituted whenever `A` was `Record` — a root declaring no `computed`, the default — leaving a dangling name in the consumer's own declarations, which then failed with `TS2304: Cannot find name 'A2'`. 7.0.2 substitutes the same position correctly, so only downstream builds saw it. Hoist the merge into an exported `MergedComputed`, top-level in `index.ts` and named in the `Entity` namespace for the same emit-nameability reason as `EntityStatic` — unexported it would only trade `TS2304` for `TS4023`. Nothing caught this because the consumer gate emitted declarations and stopped there: `TS4020` is an emit-time diagnostic, a dangling reference in the *output* is not. `typecheck` now feeds `node_modules/.emit-check` back through the 5.9.3 compiler, with no `--skipLibCheck` — measured, that flag makes the step exit 0 on the broken output. --- .changeset/extend-options-accumulate.md | 20 +++++++---- CLAUDE.md | 6 ++++ docs/typedoc.json | 1 + examples/billing-domain/package.json | 2 +- examples/billing-domain/src/emit-guards.ts | 15 +++++++-- packages/entity/src/entity.ts | 4 +++ packages/entity/src/index.ts | 15 ++++++++- packages/entity/src/types.ts | 39 ++++++++++++++++++---- 8 files changed, 84 insertions(+), 18 deletions(-) diff --git a/.changeset/extend-options-accumulate.md b/.changeset/extend-options-accumulate.md index 3d4f2fb..3ec7b14 100644 --- a/.changeset/extend-options-accumulate.md +++ b/.changeset/extend-options-accumulate.md @@ -70,10 +70,16 @@ 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 three constraints. `Entity.Static`, `Entity.Abstract` -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 all three are named in consumers' emitted declarations, which is why it is -listed here. +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 721055a..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** 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..94252ae 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" }, "dependencies": { "@btravstack/entity": "workspace:*", diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index f7edb05..3092012 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,17 @@ * 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 `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`. Never add `--skipLibCheck` to that step: + * it turns off `.d.ts` checking entirely and the run exits 0 on the broken + * output. 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 @@ -87,6 +97,7 @@ export type Root = Entity.Abstract< 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/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index e816e8a..1f8bc17 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, @@ -481,6 +482,7 @@ type AbstractEntitySrc< G extends PropertyKey, I extends PropertyKey, > = AbstractEntity; +type MergedComputedSrc = MergedComputed; type EntityStaticSrc< Tag extends string, S extends Fields, @@ -526,6 +528,8 @@ export declare namespace Entity { > = BaseInstanceSrc; export type ConstructionKey = ConstructionKeySrc; export type Sealed = SealedSrc; + /** A root's computed map merged with a variant's — the shape `extend` returns. */ + export type MergedComputed = MergedComputedSrc; /** * What `Entity(tag)(fields, options)` returns — the static surface itself. diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index ddb5324..973ea13 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -22,7 +22,20 @@ 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, also measured: 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`. Naming it fixes that; leaving the name unexported +// would only trade the error for `TS4023`. +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.ts b/packages/entity/src/types.ts index 1efaeca..a1b2a9b 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -51,8 +51,10 @@ export type CreateInputOf = Omit` - * 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. The same unprovable * subset relation is why `G` is a key union and not a tuple: the accumulating @@ -266,6 +268,32 @@ 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. The alias gives the emitter a name to write instead — + * which is why it is a top-level export of `index.ts`, exactly like + * `EntityStatic`. + * + * 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. * @@ -301,7 +329,7 @@ export type AbstractEntity< S2 extends Fields, A2 extends Fields = Record, const G2 extends readonly (keyof (S & S2))[] = [], - const I2 extends readonly (keyof OutputOf & A2>)[] = [], + const I2 extends readonly (keyof OutputOf>)[] = [], >( fields: S2 & OnlyNominal, options?: { @@ -310,13 +338,10 @@ export type AbstractEntity< readonly computed?: { [K in keyof A2]: ComputedFieldOf> }; readonly invariants?: readonly InvariantOf>[]; }, - // `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`. ) => EntityStatic< Tag2, S & S2, - Omit & A2, + MergedComputed, G | G2[number], I | I2[number], BehaviourOf From 7e9a0150d88f96d71d2c70331dc5f439b9cb9caf Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 01:37:15 +0200 Subject: [PATCH 6/9] test: give the billing root a computed field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BillingDocumentBase` declared none, so the two-compiler declaration pass only ever evaluated `MergedComputed` at `A = Record` — the branch that costs nothing. `period`, the accounting period derived from `issuedAt`, puts a real map on the root, so the root's schemas serialise into every variant's `.d.ts` and the `TS7056` margin is measured where it is actually spent. Both compilers emit clean and the emitted output type-checks on 5.9.3. Real modelling rather than a stub: billing reports and revenue recognition both work per period, and deriving it is what stops a stored copy disagreeing with the date it came from. --- docs/examples/billing-domain.md | 20 +++++++++++++++----- examples/billing-domain/src/index.spec.ts | 8 ++++++++ examples/billing-domain/src/root.ts | 18 +++++++++++++++++- examples/billing-domain/src/vocabulary.ts | 10 ++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 1a47062..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, @@ -138,11 +144,15 @@ final; `extend` lives only here. 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. `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: patching -`issuedAt` on an invoice is refused, though `Invoice` never mentions it. +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/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index fcf5ade..dd2a7d6 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -114,6 +114,14 @@ 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)); + // Derived, so it is not patchable — and re-derived from whatever `issuedAt` + // the construction path produced, on every variant, `CreditNote` included. + expect(Object.keys(Invoice.updateInput.shape)).not.toContain("period"); +}); + 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. 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..a88ce3a 100644 --- a/examples/billing-domain/src/vocabulary.ts +++ b/examples/billing-domain/src/vocabulary.ts @@ -16,6 +16,16 @@ 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. + */ +export const AccountingPeriod = z + .string() + .regex(/^\d{4}-\d{2}$/) + .brand("AccountingPeriod"); + export const Currency = z.enum(["EUR", "USD", "GBP"]); /** From 001de6237f4e6236b423f91778822d39b2b57624 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 01:37:25 +0200 Subject: [PATCH 7/9] test: pin the instance-level residue of a redefined computed key A variant redefining a root's computed key gets its own type on `Entity.Output`, but the *instance* keeps the root's brand intersected in, because a root's instance type is carried unmapped and subtracting from it is what `TS2425` forbids. That was described in prose beside the test and asserted nowhere, so the day it changed nothing would have said so. --- packages/entity/src/base.test-d.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/entity/src/base.test-d.ts b/packages/entity/src/base.test-d.ts index fe8efd2..ce8603f 100644 --- a/packages/entity/src/base.test-d.ts +++ b/packages/entity/src/base.test-d.ts @@ -141,6 +141,15 @@ test("a redefined computed key takes the variant's type, not an intersection", ( // @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; From d77994b7563452cd9911141ce41c4ada560f5a65 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 01:51:27 +0200 Subject: [PATCH 8/9] docs: seat MergedComputed on the declaration-emit list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/reference/types.md` is the canonical statement of the emit-nameability exception and still said seven. Added `MergedComputed` to the count, the import block, the namespace-alias sentence and the per-name rationale bullets, and noted that the fixture now type-checks what it emitted — which is the step that found this one. Two off-by-one counts in `entity.ts` ("none of the three", "the same reason as the three above") follow the same insertion, and its namespace JSDoc said "the shape `extend` returns" where `extend` returns `EntityStatic<…>`. That one is published, since TypeDoc renders it for `Entity.MergedComputed`. Replaced the unmeasured `TS4023` claim in `index.ts` with what was actually measured. Unexporting the alias and re-running the fixture against a root declaring no `computed` does not produce `TS4023` — the emitter expands the alias structurally and the identical dangling `A2` comes back, `TS2304` and all. So naming it and exporting it are both load bearing, and the `types.ts` comment no longer implies the name alone is what the emitter writes. Widened the emitted-declaration step from `index.d.ts` to also name `emit-guards.d.ts` and `index.spec.d.ts` rather than narrowing rule 2's claim: nothing imports `emit-guards`, so the emitted form of every namespace member it names was outside the checked import graph. The wider set is clean, so this costs nothing and makes the comment true as written. `index.spec.ts` claimed "on every variant, `CreditNote` included" while exercising only `Invoice`; it now asserts on both. --- docs/reference/types.md | 30 ++++++++++++++++------ examples/billing-domain/package.json | 2 +- examples/billing-domain/src/emit-guards.ts | 21 +++++++++------ examples/billing-domain/src/index.spec.ts | 14 ++++++++-- packages/entity/src/entity.ts | 6 ++--- packages/entity/src/index.ts | 16 +++++++----- packages/entity/src/types.ts | 7 ++--- 7 files changed, 65 insertions(+), 31 deletions(-) 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/examples/billing-domain/package.json b/examples/billing-domain/package.json index 94252ae..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 && node ./node_modules/typescript-consumer/bin/tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 node_modules/.emit-check/index.d.ts" + "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 3092012..27dec29 100644 --- a/examples/billing-domain/src/emit-guards.ts +++ b/examples/billing-domain/src/emit-guards.ts @@ -15,14 +15,19 @@ * declaration emit has to walk each one. * * 2. **The gate checks the emitted declarations, not only that emitting - * succeeded.** `typecheck`'s last step feeds `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`. Never add `--skipLibCheck` to that step: - * it turns off `.d.ts` checking entirely and the run exits 0 on the broken - * output. Measured. + * 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 diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index dd2a7d6..e14a735 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -117,9 +117,19 @@ test("each variant signs the shared amount its own way", () => { 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)); - // Derived, so it is not patchable — and re-derived from whatever `issuedAt` - // the construction path produced, on every variant, `CreditNote` included. + + // 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 root's invariant guards a variant that declares none of its own", async () => { diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 1f8bc17..262e3a3 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -520,7 +520,7 @@ 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, @@ -528,13 +528,13 @@ export declare namespace Entity { > = BaseInstanceSrc; export type ConstructionKey = ConstructionKeySrc; export type Sealed = SealedSrc; - /** A root's computed map merged with a variant's — the shape `extend` returns. */ + /** 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`. */ diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index 973ea13..bb4c990 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -23,12 +23,16 @@ export { Entity } from "./entity.js"; // computed-key position and could not be named (`TS4020`, #32). Emitting // `EntityStatic<…>` by reference fixes both. Do not un-export it. // -// `MergedComputed` joins them for a narrower reason, also measured: 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`. Naming it fixes that; leaving the name unexported -// would only trade the error for `TS4023`. +// `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, diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index a1b2a9b..c878aad 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -282,9 +282,10 @@ export type BehaviourOf = This extends abstract new (...args: never[]) => * *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. The alias gives the emitter a name to write instead — - * which is why it is a top-level export of `index.ts`, exactly like - * `EntityStatic`. + * 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 From cb3fc400761ff498f70a7248d688b5054fea8f93 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 02:22:25 +0200 Subject: [PATCH 9/9] fix(example): reject an accounting period month that cannot exist --- examples/billing-domain/src/index.spec.ts | 11 +++++++++++ examples/billing-domain/src/vocabulary.ts | 7 ++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index e14a735..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, @@ -132,6 +133,16 @@ test("a variant inherits the root's computed field without re-stating it", () => 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. diff --git a/examples/billing-domain/src/vocabulary.ts b/examples/billing-domain/src/vocabulary.ts index a88ce3a..4cb3986 100644 --- a/examples/billing-domain/src/vocabulary.ts +++ b/examples/billing-domain/src/vocabulary.ts @@ -20,10 +20,15 @@ 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}-\d{2}$/) + .regex(/^\d{4}-(0[1-9]|1[0-2])$/) .brand("AccountingPeriod"); export const Currency = z.enum(["EUR", "USD", "GBP"]);