From fd6ce3978d93fd5cefaa6bf0fb4661f250d9ec32 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 16:28:02 +0200 Subject: [PATCH 1/8] feat!: move generated and immutable onto the fields as Entity.field flags --- docs/typedoc.json | 8 ++ packages/entity/src/base.spec.ts | 38 +++--- packages/entity/src/base.test-d.ts | 11 +- packages/entity/src/base.ts | 25 ++-- packages/entity/src/computed.spec.ts | 3 +- packages/entity/src/contract.spec.ts | 9 +- packages/entity/src/crud.spec.ts | 12 +- packages/entity/src/entity.test-d.ts | 46 +++---- packages/entity/src/entity.ts | 117 +++++++++--------- packages/entity/src/field.spec.ts | 53 ++++++++ packages/entity/src/field.test-d.ts | 38 ++++++ packages/entity/src/field.ts | 56 +++++++++ packages/entity/src/index.ts | 4 + packages/entity/src/shape.ts | 16 ++- packages/entity/src/types.ts | 175 ++++++++++++++------------- 15 files changed, 391 insertions(+), 220 deletions(-) create mode 100644 packages/entity/src/field.spec.ts create mode 100644 packages/entity/src/field.test-d.ts create mode 100644 packages/entity/src/field.ts diff --git a/docs/typedoc.json b/docs/typedoc.json index 18426fe..6589542 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -18,11 +18,16 @@ "CreateInputOf", "DeepReadonly", "DomainFieldMustBeBrandedOrAnEntity", + "Entry", "EntityFactory", "EntityStaticSrc", "EntityUnionSrc", "Fields", + "FieldSpecSrc", + "Flags", + "GeneratedKeys", "Generators", + "ImmutableKeys", "InputOf", "InstanceOf", "InvalidEntity", @@ -35,6 +40,9 @@ "OutputOf", "PatchOf", "RootInstance", + "SchemaOf", + "Schemas", + "SchemasOf", "SealedSrc", "SharedBase", "UpdateInputShapeOf" diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index c93d741..c2eb04f 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -9,9 +9,8 @@ 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 }, + { id: Entity.field(AccountId, { immutable: true }), label: Label }, { - immutable: ["id"], computed: { shout: Entity.computed(Upper, (d) => d.label.toUpperCase()), }, @@ -166,13 +165,14 @@ test("a variant's own schemas include both halves", () => { expect(Object.keys(Personal.output.shape).toSorted()).toEqual(["id", "kind", "label", "shout"]); }); -test("a variant's option adds to the root's, it does not replace", () => { - class Loose extends AccountBase.extend("Loose")({ note: Label }, { immutable: [] }) { +test("a variant's flags add to the root's, they do not replace", () => { + class Loose extends AccountBase.extend("Loose")({ note: Entity.field(Label, {}) }) { override describe(): string { return "loose"; } } - // the root declared `id` immutable; declaring an empty list cannot shed it + // the root flagged `id` immutable; a variant's empty flags object cannot shed + // it, and flags nothing of its own expect(Object.keys(Loose.updateInput.shape).toSorted()).toEqual(["label", "note"]); }); @@ -261,11 +261,13 @@ 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 +test("immutable flags accumulate through a behaviour-only intermediate root", () => { + // Entities are final, so flags 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"] }) { + class Audited extends Auditable.extend("Audited")({ + note: Entity.field(Label, { immutable: true }), + }) { override describe(): string { return "audited"; } @@ -274,14 +276,18 @@ test("immutable accumulates through a behaviour-only intermediate root", () => { 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("generated flags accumulate, so a variant cannot make a root's key caller-supplied", () => { + abstract class Stamped extends Entity.abstract("Stamped")({ + id: AccountId, + at: Entity.field(Label, { generated: true }), + }) {} + class Doc extends Stamped.extend("Doc")({ + note: Label, + seq: Entity.field(Label, { generated: true }), + }) {} + // `at` is the root's flag riding the field-map spread, `seq` is the + // variant's own — `createInput` keeps neither + expect(Object.keys(Doc.createInput.shape).toSorted()).toEqual(["id", "note"]); }); test("a variant redeclaring a field replaces the root's schema for that key", () => { diff --git a/packages/entity/src/base.test-d.ts b/packages/entity/src/base.test-d.ts index 6f5b4b7..8beb538 100644 --- a/packages/entity/src/base.test-d.ts +++ b/packages/entity/src/base.test-d.ts @@ -9,9 +9,8 @@ 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 }, + { id: Entity.field(AccountId, { immutable: true }), label: Label }, { - immutable: ["id"], computed: { shout: Entity.computed(Upper, (d) => d.label.toUpperCase()), }, @@ -101,16 +100,18 @@ test("a root enforces the same field rules as a fresh declaration", () => { }); test("a variant cannot shed the root's immutable keys", () => { - class Noted extends AccountBase.extend("Noted")({ note: Label }, { immutable: ["note"] }) { + class Noted extends AccountBase.extend("Noted")({ + note: Entity.field(Label, { immutable: true }), + }) { 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 + // @ts-expect-error `id` is the root's immutable flag, and flagging our own cannot shed it n.update({ id: n.id }); - // @ts-expect-error `note` is the variant's own immutable + // @ts-expect-error `note` is the variant's own immutable flag n.update({ note: n.note }); }); diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts index c443448..f44ca7c 100644 --- a/packages/entity/src/base.ts +++ b/packages/entity/src/base.ts @@ -1,7 +1,7 @@ import type { ComputedField } from "./computed.js"; import type { Invariant } from "./invariant.js"; import type { OnlyNominal } from "./shape.js"; -import type { AbstractEntity, Fields, InputOf, OutputOf } from "./types.js"; +import type { AbstractEntity, Fields, InputOf, Schemas } from "./types.js"; /** The entity builder, loosened. Passed in so this module never imports it. */ export type BuildEntity = ( @@ -45,8 +45,6 @@ const declarationOf = (receiver: object) => { /** The options `rebuild` merges rather than overwrites. */ type Mergeable = { - readonly generated?: readonly PropertyKey[]; - readonly immutable?: readonly PropertyKey[]; readonly computed?: Record; readonly invariants?: readonly Invariant[]; }; @@ -58,7 +56,9 @@ const concat = (parent: readonly T[] | undefined, child: readonly T[] | undef /** * 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. + * add rules and derived fields; it cannot drop the ones it inherits. The + * `generated`/`immutable` flags need no merging here — they ride the field-map + * spread, wrapped, so a variant inherits them with the fields themselves. * * `computed` merges per key rather than concatenating, because it is a map: * a variant adding `murmur` keeps the root's `shout`, and one redefining @@ -79,8 +79,6 @@ const rebuild = ( 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 }; @@ -89,8 +87,6 @@ const rebuild = ( { ...parent?.options, ...nextOptions, - ...(generated.length > 0 ? { generated } : {}), - ...(immutable.length > 0 ? { immutable } : {}), ...(invariants.length > 0 ? { invariants } : {}), ...(Object.keys(computed).length > 0 ? { computed } : {}), }, @@ -130,20 +126,13 @@ const defineRootExtend = (Root: object, buildEntity: BuildEntity): void => { export const createBase = (buildEntity: BuildEntity) => (name: Name) => - < - S extends Fields, - A extends Fields = Record, - const G extends readonly (keyof S)[] = [], - const I extends readonly (keyof OutputOf)[] = [], - >( + >( fields: S & OnlyNominal, options?: { - readonly generated?: G; - readonly immutable?: I; readonly computed?: { [K in keyof A]: ComputedField> }; readonly invariants?: readonly Invariant>[]; }, - ): AbstractEntity => { + ): AbstractEntity => { class Root { static readonly entityName = name; constructor() { @@ -157,5 +146,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/computed.spec.ts b/packages/entity/src/computed.spec.ts index 638ff90..bab44f3 100644 --- a/packages/entity/src/computed.spec.ts +++ b/packages/entity/src/computed.spec.ts @@ -10,9 +10,8 @@ const FullName = z.string().min(1).brand("FullName"); const Initials = z.string().min(1).brand("Initials"); class Person extends Entity("Person")( - { id: PersonId, first: NamePart, last: NamePart }, + { id: Entity.field(PersonId, { immutable: true }), first: NamePart, last: NamePart }, { - immutable: ["id"], computed: { fullName: Entity.computed(FullName, (d) => `${d.first} ${d.last}`), initials: Entity.computed(Initials, (d) => `${d.first[0]}${d.last[0]}`), diff --git a/packages/entity/src/contract.spec.ts b/packages/entity/src/contract.spec.ts index f9b7eb7..cdb71e6 100644 --- a/packages/entity/src/contract.spec.ts +++ b/packages/entity/src/contract.spec.ts @@ -11,10 +11,13 @@ const Instant = z.iso.datetime().brand("Instant"); const Label = z.string().min(1).brand("Label"); class ApiKey extends Entity("ApiKey")( - { id: ApiKeyId, orgId: OrgId.readonly(), label: Label, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "orgId", "createdAt"], + id: Entity.field(ApiKeyId, { generated: true, immutable: true }), + orgId: Entity.field(OrgId.readonly(), { immutable: true }), + label: Label, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { // a denormalised field: stored so a query can index it, re-derived on // every construction so it cannot drift from `label` computed: { diff --git a/packages/entity/src/crud.spec.ts b/packages/entity/src/crud.spec.ts index 8e1712c..b39873b 100644 --- a/packages/entity/src/crud.spec.ts +++ b/packages/entity/src/crud.spec.ts @@ -10,10 +10,14 @@ const DisplayName = z.string().min(1).brand("DisplayName"); const Instant = z.iso.datetime().brand("Instant"); class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant, trialEndsAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + trialEndsAt: Instant, + }, + { invariants: [ Entity.invariant((d) => d.trialEndsAt > d.createdAt, "trialEndsAt must be after createdAt"), ], @@ -149,7 +153,7 @@ test("Entity.renderIssue and Entity.keysOf render and normalise one issue", () = expect(Entity.renderIssue({ message: "spans the entity" })).toBe("spans the entity"); }); -test("an entity with no generated or immutable options still exposes both schemas", () => { +test("an entity with no flagged fields still exposes both schemas", () => { class Plain extends Entity("Plain")({ id: OrgId, slug: Slug }) {} expect(Object.keys(Plain.createInput.shape).toSorted()).toEqual(["id", "slug"]); expect(Object.keys(Plain.updateInput.shape).toSorted()).toEqual(["id", "slug"]); diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index 9d547a7..73e49e0 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -172,10 +172,11 @@ test("computed rejects an unbranded field", () => { test("create rejects a generated field and update rejects an immutable one", () => { const Instant = z.iso.datetime().brand("Instant"); - class Org extends Entity("Org")( - { id: OrgId, slug: Slug, createdAt: Instant }, - { generated: ["id", "createdAt"], immutable: ["id", "createdAt"] }, - ) {} + class Org extends Entity("Org")({ + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Slug, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }) {} const createOrg = Org.factory({ id: () => "x" as never, createdAt: () => "t" as never }); // @ts-expect-error `id` is generated by the domain, not supplied by the caller @@ -190,10 +191,11 @@ test("create rejects a generated field and update rejects an immutable one", () test("factory generators are functions, and must cover exactly the generated fields", () => { const Instant = z.iso.datetime().brand("Instant"); - class Org extends Entity("Org")( - { id: OrgId, slug: Slug, createdAt: Instant }, - { generated: ["id", "createdAt"], immutable: ["id"] }, - ) {} + class Org extends Entity("Org")({ + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Slug, + createdAt: Entity.field(Instant, { generated: true }), + }) {} // `as never` would defeat every assertion below — it is assignable to any // type, including a function — so these use real values instead. @@ -241,12 +243,6 @@ test("a computed field is immutable without being declared immutable", () => { void Org.updateInput.shape.slugUpper; }); -test("a misspelled immutable key is a compile error, not a silently-mutable field", () => { - // @ts-expect-error "slugg" is not a key of the decoded shape — a typo here - // must not compile, or the misspelled field is mutable by accident - Entity("Probe3")({ id: OrgId, slug: Slug }, { immutable: ["slugg"] }); -}); - test("update() preserves the subclass type and its methods", () => { class UpdateTestOrg extends Entity("UpdateTestOrg")({ id: OrgId, slug: Slug }) { shout() { @@ -293,7 +289,10 @@ test("producers are castless: from and generators take the schema's input", () = void createShouty; // a generator needs no cast either — its value goes through make - class Stamped extends Entity("Stamped")({ id: StampId, name: Slug }, { generated: ["id"] }) {} + class Stamped extends Entity("Stamped")({ + id: Entity.field(StampId, { generated: true }), + name: Slug, + }) {} const createStamped = Stamped.factory({ id: () => crypto.randomUUID(), }); @@ -326,10 +325,10 @@ test("producers are castless: from and generators take the schema's input", () = // the one narrowing: an optional generated field's generator key is still // required, since its return type now includes `undefined` const OptionalId = z.uuid().brand("OptionalId"); - class Optional extends Entity("Optional")( - { id: StampId, theField: OptionalId.optional() }, - { generated: ["theField"] }, - ) {} + class Optional extends Entity("Optional")({ + id: StampId, + theField: Entity.field(OptionalId.optional(), { generated: true }), + }) {} // @ts-expect-error `theField`'s generator is required even though the field is optional Optional.factory({}); Optional.factory({ theField: () => undefined }); @@ -337,10 +336,11 @@ test("producers are castless: from and generators take the schema's input", () = test("the helper types name each shape", () => { const Instant = z.iso.datetime().brand("Instant"); - class Org extends Entity("Org")( - { id: OrgId, slug: Slug, createdAt: Instant }, - { generated: ["id", "createdAt"], immutable: ["id", "createdAt"] }, - ) {} + class Org extends Entity("Org")({ + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Slug, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }) {} const wire: Entity.Input = { id: "x" as z.infer, diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index d0b6f32..3e4bb54 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -7,6 +7,7 @@ import { createBase, record } from "./base.js"; import { computed, type ComputedField } from "./computed.js"; import { deepEqual } from "./equal.js"; import { InvalidEntity } from "./errors.js"; +import { field, isFieldSpec, type FieldSpec, type Flags } from "./field.js"; import { deepFreeze } from "./freeze.js"; import { invariant, type Invariant } from "./invariant.js"; import { keysOf, renderIssue } from "./issues.js"; @@ -25,9 +26,13 @@ import type { InputOf, EntityStatic, Fields, + GeneratedKeys, + ImmutableKeys, MergedComputed, MergedFields, PatchOf, + Schemas, + SchemasOf, Sealed, UpdateInputShapeOf, } from "./types.js"; @@ -57,28 +62,21 @@ const maskOf = (keys: readonly PropertyKey[]) => * for `P.tag` matching and never reaches the wire. */ export function Entity(tag: Tag) { - return function < - S extends Fields, - A extends Fields = Record, - const G extends readonly (keyof S)[] = [], - const I extends readonly (keyof OutputOf)[] = [], - >( + return function >( fields: S & OnlyNominal, options?: { - readonly generated?: G; - readonly immutable?: I; readonly computed?: { [K in keyof A]: ComputedField> }; // The *declared* fields, not `OutputOf`. A rule cannot read a computed // 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 - // list — TS won't reduce `Exclude` (or the equivalent - // for `I`) to `never` even though the constraint implies it — so this - // calls through a simplified signature rather than fight it. + // `.omit()`'s mask can't be satisfied by a mask built from a key list the + // flags derive at runtime — the shape is still generic here, so TS cannot + // relate those keys to `keyof S` — so this calls through a simplified + // signature rather than fight it. // // The empty-key branch rebuilds rather than returning `o`. Returning the // argument made `input === output === createInput` for any entity with no @@ -88,8 +86,8 @@ export function Entity(tag: Tag) { // `z.globalRegistry` under distinct ids silently kept only the last, and // `z.toJSONSchema` emitted one `$def` that all three properties `$ref`'d. // `contract.spec.ts` missed it because its fixture declares both options. - const omitBy = (o: z.ZodObject, keys: readonly PropertyKey[]) => - (o.omit as (m: Record) => z.ZodObject)(maskOf(keys)); + const omitBy = (o: z.ZodObject, keys: readonly PropertyKey[]) => + (o.omit as (m: Record) => z.ZodObject)(maskOf(keys)); /** [key, validate its output, produce it] per computed field. */ const computedFields = Object.entries( @@ -99,12 +97,16 @@ export function Entity(tag: Tag) { // `.extend({})` on the empty branch for the same reason as `omitBy`: the // four schema members must be four distinct objects, or a consumer keying a // registry by identity silently loses three of them. - const output = (input as z.ZodObject).extend( + const output = (input as unknown as z.ZodObject).extend( Object.fromEntries(computedFields.map(([k, f]) => [k, f.schema])), - ) as unknown as z.ZodObject; + ) as unknown as z.ZodObject & A>; - const generatedKeys = options?.generated ?? []; - const immutableKeys = options?.immutable ?? []; + const generatedKeys = Object.entries(fields) + .filter(([, v]) => isFieldSpec(v) && v.flags.generated) + .map(([k]) => k); + const immutableKeys = Object.entries(fields) + .filter(([, v]) => isFieldSpec(v) && v.flags.immutable) + .map(([k]) => k); /** * Every key `updateInput` omits: the declared immutable ones, plus the @@ -119,13 +121,15 @@ export function Entity(tag: Tag) { ]; /** what a caller may send to create */ - const createInput = omitBy(input as z.ZodObject, generatedKeys) as z.ZodObject< - Omit - >; + const createInput = omitBy( + input as unknown as z.ZodObject, + generatedKeys, + ) as unknown as z.ZodObject, GeneratedKeys>>; /** what a caller may send to update */ - const updateInput = omitBy(output as z.ZodObject, frozenKeys).partial() as z.ZodObject< - UpdateInputShapeOf - >; + const updateInput = omitBy( + output as unknown as z.ZodObject, + frozenKeys, + ).partial() as unknown as z.ZodObject>>; type OutputShape = OutputOf; type InputShape = InputOf; @@ -148,7 +152,7 @@ export function Entity(tag: Tag) { * ignored there. Rehydrating data and patching it are different acts: one * heals what is already written, the other states an intent. */ - const immutableNames = new Set(immutableKeys as readonly string[]); + const immutableNames = new Set(immutableKeys); const computedNames = new Set(computedFields.map(([key]) => key)); const declaredNames = new Set(dataKeys.map(String)); @@ -385,8 +389,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) }); @@ -394,8 +398,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 @@ -406,7 +410,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 @@ -430,7 +434,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; }; } @@ -443,6 +447,7 @@ export function Entity(tag: Tag) { * that test on its own, and is grouped anyway so the rule has no exceptions. */ Entity.computed = computed; +Entity.field = field; Entity.invariant = invariant; Entity.union = union; Entity.abstract = createBase(Entity as unknown as BuildEntity); @@ -468,31 +473,25 @@ Entity.renderIssue = renderIssue; * `examples/billing-domain/src/emit-guards.ts` is a failure here, not noise. */ type ComputedFieldSrc = ComputedField; +type FieldSpecSrc = FieldSpec; type InvariantSrc = Invariant; type EntityUnionSrc = EntityUnion; type ConstructionKeySrc = ConstructionKey; type SealedSrc = Sealed; -type BaseInstanceSrc = BaseInstance< +type BaseInstanceSrc = BaseInstance; +type AbstractEntitySrc = AbstractEntity< + Name, S, - A, - I + A >; -type AbstractEntitySrc< - Name extends string, - S extends Fields, - A extends Fields, - G extends PropertyKey, - I extends PropertyKey, -> = AbstractEntity; -type MergedComputedSrc = MergedComputed; +type MergedComputedSrc = MergedComputed; type MergedFieldsSrc = MergedFields; type EntityStaticSrc< Tag extends string, S extends Fields, - A extends Fields, - G extends PropertyKey, - I extends PropertyKey, -> = EntityStatic; + A extends Schemas, + B = Record, +> = EntityStatic; export declare namespace Entity { /** What the wire sends — for mapper and request signatures. */ @@ -510,6 +509,9 @@ export declare namespace Entity { /** One derived field: its schema, and the function that produces it. */ export type ComputedField = ComputedFieldSrc; + /** A schema plus its flags — what `Entity.field(...)` returns. */ + export type FieldSpec = FieldSpecSrc; + /** One whole-entity rule: the predicate, and what to say when it fails. */ export type Invariant = InvariantSrc; @@ -524,15 +526,11 @@ export declare namespace Entity { // Exported only so a consumer's emitted declarations can name them — none of // 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 PropertyKey, - > = BaseInstanceSrc; + export type BaseInstance = 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; + export type MergedComputed = MergedComputedSrc; /** A root's field map merged with a variant's — what `extend` hands `Static` as its `S`. */ export type MergedFields = MergedFieldsSrc; @@ -546,19 +544,16 @@ export declare namespace Entity { export type Static< Tag extends string, S extends Fields, - A extends Fields, - G extends PropertyKey, - I extends PropertyKey, - > = EntityStaticSrc; + A extends Schemas, + B = Record, + > = EntityStaticSrc; /** What `Entity.abstract(name)(fields, options)` returns. */ export type Abstract< Name extends string, S extends Fields, - A extends Fields, - G extends PropertyKey, - I extends PropertyKey, - > = AbstractEntitySrc; + A extends Schemas, + > = AbstractEntitySrc; /** * The instance type of an entity or a union — one line that cannot drift out diff --git a/packages/entity/src/field.spec.ts b/packages/entity/src/field.spec.ts new file mode 100644 index 0000000..91c4bf8 --- /dev/null +++ b/packages/entity/src/field.spec.ts @@ -0,0 +1,53 @@ +import { P } from "unthrown"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const Id = z.uuid().brand("Id"); +const Slug = z.string().min(1).brand("Slug"); +const Name = z.string().min(1).brand("Name"); + +class Organization extends Entity("Organization")({ + id: Entity.field(Id, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: Name, +}) {} + +const id = "0199b1f4-1b1e-7000-8000-000000000000"; + +test("flags derive createInput and updateInput", () => { + expect(Object.keys(Organization.createInput.shape).toSorted()).toEqual(["name", "slug"]); + expect(Object.keys(Organization.updateInput.shape).toSorted()).toEqual(["name"]); +}); + +test("a flagged field still parses and freezes like a bare one", () => { + const org = Organization.make({ id, slug: "acme", name: "Acme" }).getOrThrow(); + expect(org.slug).toBe("acme"); + expect(Object.isFrozen(org)).toBe(false); // instance stays extensible, as pinned in entity.spec +}); + +test("update refuses an immutable-flagged key with the same message as before", () => { + const org = Organization.make({ id, slug: "acme", name: "Acme" }).getOrThrow(); + const message = org.update({ slug: "other" } as never).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"); +}); + +test("a factory demands exactly the generated-flagged keys", () => { + const create = Organization.factory({ id: () => crypto.randomUUID() }); + const org = create({ slug: Slug.parse("acme"), name: Name.parse("Acme") }).getOrThrow(); + expect(org.id).toMatch(/^[0-9a-f-]{36}$/); +}); + +test("an entity class is still a legal flagged field, yielding real instances", () => { + class Wrapper extends Entity("Wrapper")({ + id: Entity.field(Id, { generated: true }), + owner: Entity.field(Organization, { immutable: true }), + }) {} + const w = Wrapper.make({ id, owner: { id, slug: "acme", name: "Acme" } }).getOrThrow(); + expect(w.owner).toBeInstanceOf(Organization); +}); diff --git a/packages/entity/src/field.test-d.ts b/packages/entity/src/field.test-d.ts new file mode 100644 index 0000000..cbef31e --- /dev/null +++ b/packages/entity/src/field.test-d.ts @@ -0,0 +1,38 @@ +import { test } from "vitest"; +import { z } from "zod"; + +import { Entity } from "./index.js"; + +const Id = z.uuid().brand("Id"); +const Slug = z.string().min(1).brand("Slug"); + +test("flags are extracted precisely", () => { + class Org extends Entity("Org")({ + id: Entity.field(Id, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: z.string().min(1).brand("Name"), + }) {} + const org = Org.make({}).getOrThrow(); + // instance data type unwraps to the schema's output — bare or flagged alike + const s: z.infer = org.slug; + void s; + // @ts-expect-error `slug` is immutable-flagged — not patchable + org.update({ slug: org.slug }); + org.update({ name: org.name }); + const create = Org.factory({ id: () => crypto.randomUUID() }); + void create; + // @ts-expect-error `slug` is not generated — the factory must not accept a generator for it + Org.factory({ id: () => "", slug: () => "" }); +}); + +test("an unbranded schema is rejected inside Entity.field too", () => { + // @ts-expect-error same named rejection as a bare unbranded field + Entity.field(z.string(), { immutable: true }); +}); + +test("the removed options are gone", () => { + // @ts-expect-error `generated` is no longer an option — flag the field instead + Entity("Gone")({ id: Id }, { generated: ["id"] }); + // @ts-expect-error `immutable` is no longer an option — flag the field instead + Entity("Gone2")({ id: Id }, { immutable: ["id"] }); +}); diff --git a/packages/entity/src/field.ts b/packages/entity/src/field.ts new file mode 100644 index 0000000..03c3b5b --- /dev/null +++ b/packages/entity/src/field.ts @@ -0,0 +1,56 @@ +import type { z } from "zod"; + +import type { OnlyNominal } from "./shape.js"; + +export type Flags = { readonly generated: boolean; readonly immutable: boolean }; + +/** + * One flagged field: the schema, held — never impersonated. Anything standing + * in front of an entity-class field breaks `make`, which constructs through + * `this` (`TypeError: Ctor is not a constructor` — measured), so the spec + * object is the only shape a marker may take. + */ +export type FieldSpec = { + readonly schema: T; + readonly flags: F; +}; + +/** + * Declares a field with modifiers, public as `Entity.field`: + * + * ```ts + * id: Entity.field(OrgId, { generated: true, immutable: true }), + * ``` + * + * `generated` drops the key from `createInput` and hands it to a factory + * generator; `immutable` drops it from `updateInput` so `update` refuses it. + * The flags argument is required — the function exists to flag; an empty + * object is legal and does nothing. + */ +export function field>( + schema: T & OnlyNominal<{ value: T }>["value"], + flags: F, +): FieldSpec< + T, + { + generated: F extends { generated: true } ? true : false; + immutable: F extends { immutable: true } ? true : false; + } +> { + return { + schema: schema as T, + flags: { + generated: flags.generated === true, + immutable: flags.immutable === true, + } as { + generated: F extends { generated: true } ? true : false; + immutable: F extends { immutable: true } ? true : false; + }, + }; +} + +/** A field-map entry is a schema, or a schema with flags. */ +export const isFieldSpec = (v: unknown): v is FieldSpec => + typeof v === "object" && v !== null && "schema" in v && "flags" in v && !("_zod" in v); + +export const schemaOf = (entry: unknown): unknown => (isFieldSpec(entry) ? entry.schema : entry); diff --git a/packages/entity/src/index.ts b/packages/entity/src/index.ts index 2e07195..224defb 100644 --- a/packages/entity/src/index.ts +++ b/packages/entity/src/index.ts @@ -46,6 +46,10 @@ export type { Sealed, } from "./types.js"; +// `FieldSpec` is on the same emit path: it is the declared type of every +// flagged field in a consumer's field map, so their `.d.ts` has to name it. +export type { FieldSpec } from "./field.js"; + // Same story as `EntityStatic`: a consumer writing // `abstract class X extends Entity.abstract("X")(…) {}` emits the *underlying* // name into its declarations, not the `Entity.Abstract` path that aliases it. diff --git a/packages/entity/src/shape.ts b/packages/entity/src/shape.ts index 8181f2e..86770c6 100644 --- a/packages/entity/src/shape.ts +++ b/packages/entity/src/shape.ts @@ -1,5 +1,8 @@ import { z } from "zod"; +import { schemaOf } from "./field.js"; +import type { Fields, SchemaOf, SchemasOf } from "./types.js"; + /** A field is nominal if its inferred type is branded, or is already non-interchangeable. */ type Nominal = z.core.$brand | boolean; @@ -85,19 +88,20 @@ type FieldNameIsReservedByEntity = { */ type ReservedFieldName = "_tag" | "equals" | "toJSON" | "update"; -type OnlyNominal> = { +// Judges the *unwrapped* schema: an inline `Entity.field(...)` spec is nominal +// exactly when the schema it carries is. +type OnlyNominal = { [K in keyof T]: K extends ReservedFieldName ? FieldNameIsReservedByEntity - : IsNominalField> extends true + : IsNominalField>> extends true ? T[K] : DomainFieldMustBeBrandedOrAnEntity; }; /** The only sanctioned way to declare a domain shape. */ -export function shape>( - fields: T & OnlyNominal, -): z.ZodObject { - return z.object(fields as T); +export function shape(fields: T & OnlyNominal): z.ZodObject> { + const unwrapped = Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, schemaOf(v)])); + return z.object(unwrapped as SchemasOf); } export type { OnlyNominal }; diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 3937fb5..23d7844 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -3,18 +3,57 @@ import type { z } from "zod"; import type { ComputedField as ComputedFieldOf } from "./computed.js"; import type { InvalidEntity } from "./errors.js"; +import type { FieldSpec, Flags } from "./field.js"; import type { Invariant as InvariantOf } from "./invariant.js"; import type { OnlyNominal } from "./shape.js"; /** - * A field map's values. `z.core.$ZodType`, not `z.ZodTypeAny`: an entity class - * carries only zod's internal slots, not the full method surface, and must be - * usable as a field. Anything zod accepts in an object shape is accepted here. + * A field-map entry: a schema, or `Entity.field`'s schema-plus-flags. + * + * `z.core.$ZodType`, not `z.ZodTypeAny`: an entity class carries only zod's + * internal slots, not the full method surface, and must be usable as a field. + * Anything zod accepts in an object shape is accepted here. + */ +export type Entry = z.core.$ZodType | FieldSpec; +export type Fields = Record; + +/** + * A plain schema map. The *computed* map stays this, never `Fields`: unwrapping + * `A[K]` in inference position was measured to make `A` uninferrable, so it fell + * back to its constraint and every computed key degraded to an index signature + * (TS4111, `Property 'shout' comes from an index signature`). An inline + * `Entity.field(...)` computed is impossible anyway — see `computed.ts`. + */ +export type Schemas = Record; + +/** The schema behind an entry, flagged or bare. */ +export type SchemaOf = E extends FieldSpec ? T : E; +export type SchemasOf = { [K in keyof S]: SchemaOf }; + +/** + * The keys whose entries carry each flag. Matched on the `flags` property + * rather than on `FieldSpec<…, {…}>` — the `Flags` constraint rejects a + * partial literal in extends position (measured, TS2344-class). + * + * These are computed INSIDE `EntityStatic`/`AbstractEntity`/`BaseInstance`, + * never passed as type arguments: in argument position the printer re-carries + * the whole field map (the spike's +58%), and de-aliasing is impossible — + * alias annotation, defaulted parameter + `infer`, and mapped-object+`keyof` + * were all measured to reconstitute the alias via union-origin tracking, on + * both 7.0.2 and 5.9.3. Inside a body, `S` prints by name and the map appears + * once. Do not move these into a parameter list. */ -export type Fields = Record; +export type GeneratedKeys = { + [K in keyof S]: S[K] extends { readonly flags: { readonly generated: true } } ? K : never; +}[keyof S] & + PropertyKey; +export type ImmutableKeys = { + [K in keyof S]: S[K] extends { readonly flags: { readonly immutable: true } } ? K : never; +}[keyof S] & + PropertyKey; /** The data an entity accepts on the wire. */ -export type InputOf = z.infer>; +export type InputOf = z.infer>>; /** * The values the computed fields contribute. @@ -33,7 +72,7 @@ export type InputOf = z.infer>; * `.optional()` field keeps its optional key (`k?: T`) instead of becoming a * required `k: T | undefined`. */ -export type ComputedOf = [keyof A] extends [never] +export type ComputedOf = [keyof A] extends [never] ? Record : z.infer>; @@ -42,7 +81,7 @@ export type ComputedOf = [keyof A] extends [never] * ones. There is deliberately no `_tag` — the tag is a * non-enumerable instance property and never part of the data. */ -export type OutputOf = InputOf & ComputedOf; +export type OutputOf = InputOf & ComputedOf; /** What `create` accepts from a caller: everything the domain does not generate. */ export type CreateInputOf = Omit, G>; @@ -121,7 +160,7 @@ 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 = Partial< +export type PatchOf = Partial< Omit, I | keyof A> >; @@ -135,8 +174,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 = { - [Key in Exclude]: z.ZodOptional<(S & A)[Key]>; +export type UpdateInputShapeOf = { + [Key in Exclude]: z.ZodOptional>; }; /** @@ -187,10 +226,10 @@ 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 { +export interface BaseInstance { toJSON(): DeepReadonly>; equals(other: unknown): boolean; - update(patch: PatchOf): Result; + update(patch: PatchOf>): Result; } /** @@ -209,12 +248,10 @@ export interface BaseInstance = BaseInstance & +type ConstructedInstance = BaseInstance< + S, + A +> & DeepReadonly> & { readonly _tag: Tag; }; @@ -229,11 +266,7 @@ type ConstructedInstance< * (TS2509). `string & "Personal"` reduces to `"Personal"`, which is exactly * what the variant needs. */ -export type RootInstance = BaseInstance< - S, - A, - I -> & +export type RootInstance = BaseInstance & DeepReadonly> & { readonly _tag: string }; /** @@ -272,7 +305,7 @@ export type BehaviourOf = This extends abstract new (...args: never[]) => * * The *fields* half of the same merge is `MergedFields`, below. */ -export type MergedComputed = Omit & A2; +export type MergedComputed = Omit & A2; /** * A root's field map merged with a variant's — what `extend` hands @@ -310,14 +343,8 @@ export type MergedFields = Omit = { - new (d: Sealed>): RootInstance; +export type AbstractEntity = { + new (d: Sealed>): RootInstance; readonly entityName: Name; /** * A new entity carrying this root's fields plus more, under its own tag, and @@ -333,29 +360,15 @@ export type AbstractEntity< extend( this: This, tag: Tag2, - ): < - S2 extends Fields, - A2 extends Fields = Record, - const G2 extends readonly (keyof MergedFields)[] = [], - const I2 extends readonly (keyof OutputOf, MergedComputed>)[] = [], - >( + ): >( fields: S2 & OnlyNominal, options?: { - readonly generated?: G2; - readonly immutable?: I2; readonly computed?: { [K in keyof A2]: ComputedFieldOf>>; }; readonly invariants?: readonly InvariantOf>>[]; }, - ) => EntityStatic< - Tag2, - MergedFields, - MergedComputed, - G | G2[number], - I | I2[number], - BehaviourOf - >; + ) => EntityStatic, MergedComputed, BehaviourOf>; }; /** @@ -366,41 +379,39 @@ export type AbstractEntity< * can double as the builder's *explicit* return-type annotation, and so the * package's exported helper types (`Input`, `Output`, `CreateInput`, * `Patch`) have a single surface to read the shapes off. This is why every - * member below is expressed from `S`/`A`/`G`/`I`/`Tag` alone instead of + * member below is expressed from `S`/`A`/`Tag` alone instead of * the builder's body-local `Base`/`input`/`output`/etc. — those aren't in * scope at the annotation position, before the body that declares them. + * + * There are no `G`/`I` parameters: the key unions are computed inside the + * body from the flags `S` carries — see `GeneratedKeys` for why they must + * never move into a parameter list. */ export type EntityStatic< Tag extends string, S extends Fields, - A extends Fields, - // `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 `Generators` for the same - // failure in its `Pick` form. - G extends PropertyKey, - I extends PropertyKey, + A extends Schemas, // What the abstract root's class body contributed, or nothing. Defaulted so - // every existing five-argument spelling keeps compiling. + // the plain `Entity(tag)(fields)` spelling stays three arguments. // - // A sixth parameter lengthens every serialised instance type, which is the + // A fourth parameter lengthens every serialised instance type, which is the // `TS7056` budget — the ceiling `index.ts` records two shipped build failures - // against, and the one 5.9.3 hits sooner than 7.0.2 does. It was measured, - // not assumed: `examples/billing-domain`'s two-compiler declaration pass - // emits clean on both TypeScript 7.0.2 and 5.9.3 with this arity, no - // `TS7056` from either. The headroom it spends is the `_zod` / `~standard` - // slots below naming `ConstructedInstance & B` instead of - // spelling that intersection out a second and third time — same type, fewer - // serialised characters. Widening this further means re-running that pass. + // against, and the one 5.9.3 hits sooner than 7.0.2 does. The clean + // two-compiler pass on `examples/billing-domain` was measured at the old + // six-parameter arity; this four-parameter shape must be re-measured by that + // same gate before it counts as clean. The headroom it spends is the + // `_zod` / `~standard` slots below naming `ConstructedInstance & B` + // instead of spelling that intersection out a second and third time — same + // type, fewer serialised characters. Widening this further means re-running + // that pass. B = Record, > = { - new (d: Sealed>): ConstructedInstance & B; + new (d: Sealed>): ConstructedInstance & B; readonly entityName: Tag; - readonly input: z.ZodObject; - readonly output: z.ZodObject; - readonly createInput: z.ZodObject>; - readonly updateInput: z.ZodObject>; + readonly input: z.ZodObject>; + readonly output: z.ZodObject & A>; + readonly createInput: z.ZodObject, GeneratedKeys>>; + readonly updateInput: z.ZodObject>>; /** * The zod slots that make the class itself a schema, so it composes * directly: `z.object({ owner: Organization })`, `z.array(Organization)`, @@ -416,28 +427,28 @@ export type EntityStatic< * property, unlike a method, takes no `this` parameter to infer the receiver * from — so it states the base shape and a caller narrows with `instanceof`. */ - readonly _zod: z.ZodType & B>["_zod"]; - readonly "~standard": z.ZodType & B>["~standard"]; + readonly _zod: z.ZodType & B>["_zod"]; + readonly "~standard": z.ZodType & B>["~standard"]; /** phantom carriers, so consumers can recover the shapes for annotations */ readonly __input: InputOf; readonly __output: OutputOf; - readonly __createInput: CreateInputOf; - readonly __patch: PatchOf; + readonly __createInput: CreateInputOf>; + readonly __patch: PatchOf>; /** the *instance type* of the abstract root this was extended from, read by `Entity.union` */ readonly __base: B; /** the instance type, read by `Entity.Instance` */ - readonly __instance: ConstructedInstance & B; + readonly __instance: ConstructedInstance & B; make(this: new (d: Sealed>) => T, state: unknown): Result; // No `extend`. An entity is final — extension lives on `AbstractEntity`, // which is tagless and can therefore carry behaviour. See `BehaviourOf`. factory( this: new (d: Sealed>) => T, - generators: Generators, - ): EntityFactory; + generators: Generators>, + ): EntityFactory>; factoryAsync( this: new (d: Sealed>) => T, - generators: AsyncGenerators, - ): AsyncEntityFactory; + generators: AsyncGenerators>, + ): AsyncEntityFactory>; }; /** @@ -456,11 +467,11 @@ export type EntityStatic< * constrain the real call sites, and this type only has to survive them. */ export type Generators = { - [K in keyof S as K extends G ? K : never]: () => z.input; + [K in keyof S as K extends G ? K : never]: () => z.input>; }; export type AsyncGenerators = { - [K in keyof S as K extends G ? K : never]: () => PromiseLike>; + [K in keyof S as K extends G ? K : never]: () => PromiseLike>>; }; /** From 065b7bd8ab3719f2dfb5be8c86f91b26533cfb53 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 16:38:47 +0200 Subject: [PATCH 2/8] fix: reject a misspelled Entity.field flag name at compile time --- packages/entity/src/entity.ts | 3 ++- packages/entity/src/field.test-d.ts | 8 ++++++++ packages/entity/src/field.ts | 9 ++++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 3e4bb54..1bece42 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -85,7 +85,8 @@ export function Entity(tag: Tag) { // keying off schema identity collapsed: registering the three in // `z.globalRegistry` under distinct ids silently kept only the last, and // `z.toJSONSchema` emitted one `$def` that all three properties `$ref`'d. - // `contract.spec.ts` missed it because its fixture declares both options. + // `contract.spec.ts` missed it because its fixture leaves no branch empty: + // its fields carry `generated` flags and it declares `computed`. const omitBy = (o: z.ZodObject, keys: readonly PropertyKey[]) => (o.omit as (m: Record) => z.ZodObject)(maskOf(keys)); diff --git a/packages/entity/src/field.test-d.ts b/packages/entity/src/field.test-d.ts index cbef31e..cff692f 100644 --- a/packages/entity/src/field.test-d.ts +++ b/packages/entity/src/field.test-d.ts @@ -30,6 +30,14 @@ test("an unbranded schema is rejected inside Entity.field too", () => { Entity.field(z.string(), { immutable: true }); }); +test("a misspelled flag name is a compile error, not a silently-mutable field", () => { + // @ts-expect-error "imutable" alone is rejected (TS2561 suggests the spelling) + Entity.field(Id, { imutable: true }); + // @ts-expect-error a typo beside a correct flag is the dangerous shape — excess-property + // checking alone lets it through, and the field would be silently mutable + Entity.field(Id, { generated: true, imutable: true }); +}); + test("the removed options are gone", () => { // @ts-expect-error `generated` is no longer an option — flag the field instead Entity("Gone")({ id: Id }, { generated: ["id"] }); diff --git a/packages/entity/src/field.ts b/packages/entity/src/field.ts index 03c3b5b..80b7f3b 100644 --- a/packages/entity/src/field.ts +++ b/packages/entity/src/field.ts @@ -15,6 +15,9 @@ export type FieldSpec = { readonly flags: F; }; +/** The rejection type for a misspelled flag name — named so it survives truncation and *is* the message, `shape.ts`'s trick. */ +type UnknownFlagIsRejected = { readonly __unknownFlagIsRejected: never }; + /** * Declares a field with modifiers, public as `Entity.field`: * @@ -29,7 +32,11 @@ export type FieldSpec = { */ export function field>( schema: T & OnlyNominal<{ value: T }>["value"], - flags: F, + // Not bare `F`: a constraint is not an excess-property check, so + // `{ generated: true, imutable: true }` satisfied `Partial` and + // compiled clean — measured, and the misspelled field was silently mutable. + // The intersection maps every unknown key to the rejection type instead. + flags: F & Record, UnknownFlagIsRejected>, ): FieldSpec< T, { From ac0c513eb5e8fbf07783a4b7835eb343e8f60a09 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 16:48:58 +0200 Subject: [PATCH 3/8] feat!: a variant may not redeclare an inherited field --- packages/entity/src/base.spec.ts | 23 +++++++-------------- packages/entity/src/base.test-d.ts | 33 ++++++++++++------------------ packages/entity/src/base.ts | 14 +++++++++++++ packages/entity/src/types.ts | 15 +++++++++++++- 4 files changed, 48 insertions(+), 37 deletions(-) diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index c2eb04f..df05e55 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -290,22 +290,13 @@ test("generated flags accumulate, so a variant cannot make a root's key caller-s expect(Object.keys(Doc.createInput.shape).toSorted()).toEqual(["id", "note"]); }); -test("a variant redeclaring a field replaces the root's schema for that key", () => { - // The two schemas accept overlapping but neither-contains-the-other sets, so - // all three candidate merges are told apart rather than only two of them. - const Code5 = z.string().length(5).brand("Code5"); - const Digits = z.string().regex(/^\d+$/).brand("Digits"); - abstract class Coded extends Entity.abstract("Coded")({ id: AccountId, code: Code5 }) {} - class Numbered extends Coded.extend("Numbered")({ code: Digits }) {} - - // accepted by the root, rejected by the variant — rules out parent-wins - expect(Numbered.make({ id, code: "abcde" }).isErr()).toBe(true); - // rejected by the root, accepted by the variant — rules out an intersection, - // which is the merge this key's *type* used to claim - expect(Numbered.make({ id, code: "42" }).getOrThrow().code).toBe("42"); - // accepted by both, so the key is not simply dropped - expect(Numbered.make({ id, code: "12345" }).getOrThrow().code).toBe("12345"); - expect(Object.keys(Numbered.output.shape).toSorted()).toEqual(["code", "id"]); +test("redeclaring an inherited field is a declaration-time defect", () => { + // A redeclared field is a bug in the declaration, not caller input — the + // same ruling as union's duplicate discriminant, and it fails at the same + // moment: while the declaration is on the stack. + expect(() => (AccountBase.extend("Clash") as (f: object) => unknown)({ label: Label })).toThrow( + /label.*already declared/, + ); }); test("a variant redefining one computed key overrides that entry only", () => { diff --git a/packages/entity/src/base.test-d.ts b/packages/entity/src/base.test-d.ts index 8beb538..0e17a69 100644 --- a/packages/entity/src/base.test-d.ts +++ b/packages/entity/src/base.test-d.ts @@ -156,26 +156,19 @@ test("a redefined computed key takes the variant's type, not an intersection", ( void instanceAsUpper; }); -test("a redeclared field takes the variant's brand, not an intersection", () => { - class Retyped extends AccountBase.extend("Retyped")({ label: Upper }) { - override describe(): string { - return "retyped"; - } - } - // `Entity.Output` for the same reason as `Louder` above: an instance is that - // intersected with `BehaviourOf`, which carries the root's `label` - // unmapped, so only the surfaces reading `S` alone are honest. - type Out = Entity.Output; - // the root typed `label` as Label; the variant redeclares it as Upper. As with - // `Louder` above, the negative is the whole guard: under a plain `S & S2` this - // key would be `Label & Upper`, assignable to either constituent, so both - // lines would still compile and the regression would show up as the directive - // going **unused** (`TS2578`) rather than as a type error here. - const asUpper: z.infer = null as unknown as Out["label"]; - void asUpper; - // @ts-expect-error the root's `Label` brand is gone, not intersected in - const asLabel: z.infer = null as unknown as Out["label"]; - void asLabel; +test("a variant cannot redeclare an inherited field with a different brand", () => { + // The old semantics here were "the variant's brand wins, not an + // intersection" — moot now that redeclaring `label` at all is rejected. + // @ts-expect-error `label` is already declared by the root + AccountBase.extend("Retyped")({ label: Upper }); +}); + +test("a variant cannot redeclare an inherited field, flagged or not", () => { + // AccountBase declares `label` — reuse the file's existing root fixture + // @ts-expect-error `label` is already declared by the root + AccountBase.extend("Clash")({ label: Label }); + // @ts-expect-error flagged redeclaration is equally rejected + AccountBase.extend("Clash2")({ label: Entity.field(Label, { immutable: true }) }); }); void Business; diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts index f44ca7c..a3e7ebd 100644 --- a/packages/entity/src/base.ts +++ b/packages/entity/src/base.ts @@ -76,6 +76,20 @@ const rebuild = ( nextOptions: Record | undefined, ): { prototype: object } => { const parent = declarationOf(receiver); + + const clashes = Object.keys(nextFields).filter((k) => parent !== undefined && k in parent.fields); + if (clashes.length > 0) { + // A redeclared field is a bug in the declaration, not caller input. + // Failing here names the key while the declaration is on the stack — + // the same precedent as union()'s duplicate-discriminant defect. It is + // also what keeps a variant's flags unsheddable: a bare-schema + // redeclaration used to silently drop the root's `Entity.field` flags. + // oxlint-disable-next-line unthrown/no-throw + throw new Error( + `${nextTag}: field(s) ${clashes.map((k) => JSON.stringify(k)).join(", ")} already declared by the root — a variant adds fields, it does not redeclare them.`, + ); + } + const parentOptions = parent?.options as Mergeable | undefined; const childOptions = nextOptions as Mergeable | undefined; diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index 23d7844..e79ed68 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -332,9 +332,22 @@ export type MergedComputed = Omit = Omit & S2; +/** The error message is the type name, same trick as `shape.ts`'s rejections. */ +type FieldAlreadyDeclaredByTheRoot = { readonly __fieldAlreadyDeclaredByTheRoot: never }; + +/** Rejects any `S2` key the root `S` already declares, flagged or not. */ +type NoRedeclaredKeys = { + [K in keyof S2]: K extends keyof S ? FieldAlreadyDeclaredByTheRoot : S2[K]; +}; + /** * What `Entity.abstract(name)(fields, options?)` returns. * @@ -361,7 +374,7 @@ export type AbstractEntity>( - fields: S2 & OnlyNominal, + fields: S2 & OnlyNominal & NoRedeclaredKeys, options?: { readonly computed?: { [K in keyof A2]: ComputedFieldOf>>; From eddd2534df68ff26a0e97b09f06828e049080b3c Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 17:06:12 +0200 Subject: [PATCH 4/8] test: convert the billing domain to Entity.field flags Converts organization.ts, root.ts and index.ts's two variants from the generated/immutable options-list spelling to Entity.field(schema, { generated, immutable }) inline flags (Tasks 1-2). vocabulary.ts and index.spec.ts are untouched. emit-guards.ts drops Entity.BaseInstance/Static/Abstract to their reduced arity (2/4/3 type arguments) and adds a named Entity.FieldSpec guard. Structural proof: grep for GeneratedKeys; 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, never>; -// `G` and `I` are unions of keys, so the empty case is `never` rather than `[]`. +export type Spec = Entity.FieldSpec; +export type Base = Entity.BaseInstance<{ slug: typeof Slug }, Record>; +// `B` defaults to `Record` — written out anyway so declaration +// emit walks the fourth argument too. export type Static = Entity.Static< "Organization", { slug: typeof Slug }, Record, - never, - never + Record >; export type Members = Entity.Union<"kind", [typeof Invoice, typeof CreditNote]>; export type AnyDocument = Entity.Instance; @@ -98,9 +99,7 @@ export type OneInvoice = Entity.Instance; export type Root = Entity.Abstract< "BillingDocument", { total: typeof Money }, - Record, - never, - never + Record >; export type Merged = Entity.MergedComputed<{ label: typeof DisplayLabel }, Record>; // The `Record` second argument is the shape that found the diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index 1ca3da8..b0c6613 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -36,16 +36,14 @@ export * from "./vocabulary.js"; export class Invoice extends BillingDocumentBase.extend("Invoice")( { - id: InvoiceId, - kind: z.literal("INVOICE"), + id: Entity.field(InvoiceId, { generated: true, immutable: true }), + kind: Entity.field(z.literal("INVOICE"), { generated: true, immutable: true }), lines: z.array(LineItem), status: InvoiceStatus, dunningReasons: z.array(DunningReason), level: Level, }, { - generated: ["id", "kind"], - immutable: ["id", "kind"], invariants: [ Entity.invariant( (d) => d.status !== "VOID" || d.dunningReasons.length === 0, @@ -69,13 +67,11 @@ export class Invoice extends BillingDocumentBase.extend("Invoice")( * of the root, sharing the `kind` discriminant, is what lets both travel down * one channel and come back as the right class. */ -export class CreditNote extends BillingDocumentBase.extend("CreditNote")( - { id: CreditNoteId, kind: z.literal("CREDIT_NOTE"), against: InvoiceId }, - { - generated: ["id", "kind"], - immutable: ["id", "against", "kind"], - }, -) { +export class CreditNote extends BillingDocumentBase.extend("CreditNote")({ + id: Entity.field(CreditNoteId, { generated: true, immutable: true }), + kind: Entity.field(z.literal("CREDIT_NOTE"), { generated: true, immutable: true }), + against: Entity.field(InvoiceId, { immutable: true }), +}) { override signedAmount(): number { return -this.total.amount; } diff --git a/examples/billing-domain/src/organization.ts b/examples/billing-domain/src/organization.ts index ac17a82..56e21f6 100644 --- a/examples/billing-domain/src/organization.ts +++ b/examples/billing-domain/src/organization.ts @@ -3,19 +3,22 @@ import { Entity } from "@btravstack/entity"; import { DisplayLabel, DisplayName, Instant, OrganizationId, Slug } from "./vocabulary.js"; /** - * `generated` names the fields the domain produces rather than the caller, so - * they drop out of `createInput`. `immutable` names the ones `update` refuses. - * `computed` is re-derived on every construction path, so it cannot drift from - * its sources. + * `Entity.field(schema, { generated, immutable })` flags the fields the + * domain produces rather than the caller (`generated`, dropped from + * `createInput`) and the ones `update` refuses (`immutable`). `computed` is + * re-derived on every construction path, so it cannot drift from its sources. * * A plain, rootless entity: nothing else shares its fields, so there is nothing * for a root to hold. */ export class Organization extends Entity("Organization")( - { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrganizationId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { displayLabel: Entity.computed(DisplayLabel, (d) => `${d.name} (${d.slug})`), }, diff --git a/examples/billing-domain/src/root.ts b/examples/billing-domain/src/root.ts index 289a485..f13588f 100644 --- a/examples/billing-domain/src/root.ts +++ b/examples/billing-domain/src/root.ts @@ -13,7 +13,7 @@ import { AccountingPeriod, Instant, Money } from "./vocabulary.js"; * zod schema, so it parses back to a real `Organization`, behaviour and all. * * **Exported, and in a module of its own, on purpose.** A root's instance type - * is the sixth type argument of every variant's `EntityStatic`, so it lands in + * is the fourth type argument of every variant's `EntityStatic`, so it lands in * the emitted `.d.ts` of whatever module the variants are exported from — and * it lands there two different ways. Kept beside its variants, TypeScript * synthesises a local `declare abstract class`; across a module boundary it has @@ -37,10 +37,12 @@ import { AccountingPeriod, Instant, Money } from "./vocabulary.js"; * on the value. */ export abstract class BillingDocumentBase extends Entity.abstract("BillingDocument")( - { issuedTo: Organization, total: Money, issuedAt: Instant }, { - generated: ["issuedAt"], - immutable: ["issuedAt", "issuedTo"], + issuedTo: Entity.field(Organization, { immutable: true }), + total: Money, + issuedAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { period: Entity.computed(AccountingPeriod, (d) => d.issuedAt.slice(0, 7)), }, From a8beff98dad913c8f64f83326bfc27640691df6d Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 17:24:43 +0200 Subject: [PATCH 5/8] fix: stop field()'s parameter from breaking zod's alias preservation Review of the billing-domain conversion found field()'s parameter, schema: T & OnlyNominal<{ value: T }>["value"], intersecting T with a type-level check at an inference site. That intersection made the emitter give up on writing a nameable branded alias (z.core.$ZodBranded<...>) by reference for every flagged field, expanding it structurally instead (ZodString & { _zod: { output: string & $brand<"Slug"> } }) - ~42 B per appearance, 32% of the conversion's emitted-size delta. The map-level OnlyNominal (shape.ts, applied at every Entity(...)/ Entity.abstract(...)/.extend(...) call site) already unwraps FieldSpec via SchemaOf before judging nominality, so field()'s own check was redundant - verified by respelling the parameter to bare T and confirming an unbranded schema wrapped in Entity.field(...) is still rejected, just at the field-map key instead of at the field() call. field.test-d.ts's rejection pin moved to match. Re-measured examples/billing-domain/node_modules/.emit-check/*.d.ts after rebuilding: total emitted size drops from 26,497 B to 25,623 B (-874 B, matching the reviewer's predicted swing to the byte). Delta vs the true baseline (23,729 B, measured at origin/main 447e8d6) is now +1,894 B (+8.0%), down from +2,768 B (+11.7%): | File | Baseline | Post-fix | Delta | Delta% | |--------------------|---------:|---------:|-------:|-------:| | index.d.ts | 10,145 | 11,499 | +1,354 | +13.3% | | organization.d.ts | 1,234 | 1,526 | +292 | +23.7% | | root.d.ts | 3,023 | 3,186 | +163 | +5.4% | | emit-guards.d.ts | 4,723 | 4,808 | +85 | +1.8% | | vocabulary.d.ts | 4,593 | 4,593 | 0 | 0.0% | | index.spec.d.ts | 11 | 11 | 0 | 0.0% | | total | 23,729 | 25,623 | +1,894 | +8.0% | Four-step billing-domain typecheck and the whole-repo gate (format, lint, typecheck, test x205, knip, build) re-run clean. GeneratedKeys { Org.factory({ id: () => "", slug: () => "" }); }); -test("an unbranded schema is rejected inside Entity.field too", () => { - // @ts-expect-error same named rejection as a bare unbranded field - Entity.field(z.string(), { immutable: true }); +test("an unbranded schema wrapped in Entity.field is rejected at the field map, not the call", () => { + // `field()` itself does no nominal check — the map-level `OnlyNominal` already unwraps + // `FieldSpec` (via `SchemaOf`) before judging, so a second check here would be redundant. + // The rejection surfaces at the map key rather than at this call, which type-checks fine + // on its own. + const spec = Entity.field(z.string(), { immutable: true }); + class Bad extends Entity("Bad")({ + // @ts-expect-error same named rejection as a bare unbranded field + id: spec, + }) {} + void Bad; }); test("a misspelled flag name is a compile error, not a silently-mutable field", () => { diff --git a/packages/entity/src/field.ts b/packages/entity/src/field.ts index 80b7f3b..51e66cc 100644 --- a/packages/entity/src/field.ts +++ b/packages/entity/src/field.ts @@ -1,7 +1,5 @@ import type { z } from "zod"; -import type { OnlyNominal } from "./shape.js"; - export type Flags = { readonly generated: boolean; readonly immutable: boolean }; /** @@ -31,7 +29,20 @@ type UnknownFlagIsRejected = { readonly __unknownFlagIsRejected: never }; * object is legal and does nothing. */ export function field>( - schema: T & OnlyNominal<{ value: T }>["value"], + // Bare `T`, not `T & OnlyNominal<{ value: T }>["value"]`: the intersection at an + // inference site measurably breaks zod's alias preservation. An unbranded schema + // intersected this way still resolved and was rejected, but every *branded* one paid + // for it too — `$ZodBranded` expanded to + // `ZodString & { _zod: { output: string & $brand<"Slug"> } }` in the emitted `.d.ts`, + // ~42 bytes per appearance (measured: -874 B / 21 flagged-field appearances in the + // billing-domain fixture's emitted .d.ts, removing this intersection), for a check + // that never had anything left to reject once the map-level check below ran. + // The map-level `OnlyNominal` (`shape.ts`, applied at every `Entity(...)`/`extend` + // call site) already unwraps `FieldSpec` through `SchemaOf` before judging nominality, + // so an unbranded schema placed in `Entity.field(...)` is still rejected — the error + // just surfaces at the field-map key instead of at this call. See the map-position + // pin in `field.test-d.ts`. + schema: T, // Not bare `F`: a constraint is not an excess-property check, so // `{ generated: true, imutable: true }` satisfied `Partial` and // compiled clean — measured, and the misspelled field was silently mutable. From 88c127d283809f43ca76ffedc99353238fc18ed0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 17:41:33 +0200 Subject: [PATCH 6/8] docs: teach Entity.field and the redeclaration rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert every fenced declaration off the `generated`/`immutable` option keys onto `Entity.field(schema, flags)`, and state the two rules that came with it: a variant may not redeclare an inherited field, and therefore may not flag a root-declared one either. - `reference/declaration.md` gains an `Entity.field` section with the flag table, the misspelled-flag rejection and the map-level nominal check; the options table shrinks to `computed`/`invariants`; the extend merge table loses the two list rows and gains the flags-ride-their-fields row plus the redeclaration forbid with its defect message. - `reference/types.md` records the three arity reductions and why a key union in argument position cannot be de-aliased, and adds `FieldSpec` to the declaration-emit names (nine → ten). - `typedoc.json`: `NoRedeclaredKeys` / `UnknownFlagIsRejected` added, `ComputedOf` / `Entry` dropped — the docs build is warning-free again. - `CLAUDE.md`: `field.ts` in the module list, `base.ts`'s merge rewritten, the de-aliasing dead end added to the measured-comments list. --- .changeset/entity-field.md | 129 +++++++++++++++++++++ CLAUDE.md | 66 ++++++++--- README.md | 22 ++-- docs/examples/billing-api.md | 6 +- docs/examples/billing-domain.md | 63 ++++++---- docs/examples/index.md | 3 +- docs/how-to/evolve-an-entity.md | 12 +- docs/how-to/http-contract.md | 10 +- docs/how-to/model-an-aggregate.md | 5 + docs/how-to/test-domain-logic.md | 2 +- docs/index.md | 11 +- docs/reference/declaration.md | 183 +++++++++++++++++++++--------- docs/reference/entry-points.md | 11 +- docs/reference/schemas.md | 4 +- docs/reference/types.md | 45 ++++++-- docs/tutorial/getting-started.md | 47 ++++---- docs/typedoc.json | 4 +- packages/entity/README.md | 27 +++-- 18 files changed, 486 insertions(+), 164 deletions(-) create mode 100644 .changeset/entity-field.md diff --git a/.changeset/entity-field.md b/.changeset/entity-field.md new file mode 100644 index 0000000..3132d10 --- /dev/null +++ b/.changeset/entity-field.md @@ -0,0 +1,129 @@ +--- +"@btravstack/entity": minor +--- + +Add `Entity.field(schema, flags)` and move `generated` / `immutable` off the +options object onto the fields themselves. + +```ts +// before +class Organization extends Entity("Organization")( + { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant }, + { + generated: ["id", "createdAt"], + immutable: ["id", "createdAt", "slug"], + }, +) {} + +// after +class Organization extends Entity("Organization")({ + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), +}) {} +``` + +A field that carries no flag stays a bare schema. The flags argument is +required — the function exists to flag — and a misspelled flag name is now a +compile error: a constraint is not an excess-property check, so +`{ generated: true, imutable: true }` used to compile clean and leave the field +silently mutable. + +Nothing changes at runtime for a declaration that migrates one-for-one: +`createInput`, `updateInput`, the factory's generator map and `update`'s +rejection all derive from the same key sets, now read off the field map instead +of two lists beside it. + +**Cost, measured.** A consumer's emitted declarations grow ~90 bytes per +_appearance_ of a flagged field — the billing-domain fixture's 10 flagged +fields appear 21 times across its `.d.ts` set, for +1,894 B / +8.0% in total. +The appearance count is a property of a domain's shape (a root shared by two +variants, an entity held as another entity's field), not a constant. The naive +design measured +57.8%; see the third item below for why this one does not. + +## Breaking: the `generated` and `immutable` options are gone + +Both keys are rejected on the options object of `Entity(tag)(…)`, +`Entity.abstract(name)(…)` and `Root.extend(tag)(…)`. `computed` and +`invariants` are what remains, and an entity declaring neither passes no options +object at all. The migration is mechanical: + +| Before | After | +| ------------------------------------------ | ------------------------------------------------------------ | +| `{ generated: ["id"] }` | `id: Entity.field(Id, { generated: true })` | +| `{ immutable: ["id"] }` | `id: Entity.field(Id, { immutable: true })` | +| `{ generated: ["id"], immutable: ["id"] }` | `id: Entity.field(Id, { generated: true, immutable: true })` | +| a key in neither list | the bare schema, unchanged | + +A key that appeared in a list but not in the field map was already a compile +error and has no migration. + +## Breaking: `Entity.Static`, `Entity.Abstract` and `Entity.BaseInstance` lost type parameters + +| Type | Now | Was | +| --------------------- | ----------------- | -------------------- | +| `Entity.Static` | `` | `` | +| `Entity.Abstract` | `` | `` | +| `Entity.BaseInstance` | `` | `` | + +Their top-level spellings moved with them: `EntityStatic` (six +parameters to four — it was the one place `B` was already exposed), +`AbstractEntity` and `BaseInstance`. The dropped parameters were the generated- +and immutable-key unions; they are computed inside each body from the flags `S` +carries. Hand-written annotations drop the extra arguments — +`Entity.Static<"Org", S, A, never, never>` becomes +`Entity.Static<"Org", S, A>`. Declarations infer them and need no change. + +This is the reason the size cost above is +8.0% rather than +57.8%. A key union +in **type-argument** position cannot be de-aliased: the printer re-carries the +whole field map at every appearance, and an alias annotation, a defaulted +parameter plus `infer`, and a mapped-object indirection were each measured to +reconstitute the alias on both TypeScript 7.0.2 and 5.9.3. Computed inside a +body, `S` prints by name and the map appears once — with zero `GeneratedKeys<` +or `ImmutableKeys<` anywhere in the emitted output. + +## Breaking: a variant may not redeclare a field its root declares + +```ts +abstract class AccountBase extends Entity.abstract("Account")({ + id: AccountId, + label: Label, +}) {} + +AccountBase.extend("Clash")({ label: Label }); // ✗ FieldAlreadyDeclaredByTheRoot +``` + +This breaks a variant that restates an inherited field **even with no flags on +either side**, which previously compiled and simply re-declared the same schema. +The migration is to declare the field once, on the root, and delete it from the +variant. A variant that redeclared a key with a _different_ schema was already +reporting that key inconsistently (the instance property kept both brands +intersected, `TS2425`); it now has to pick one and put it on the root. + +The compile error is backed by a **declaration-time defect**, thrown while the +declaration is on the stack, so a declaration reaching `extend` from JavaScript +or through a cast fails the same way: + +``` +Clash: field(s) "label" already declared by the root — a variant adds fields, +it does not redeclare them. +``` + +`computed` is unaffected: it still merges per key, and a variant may still +replace one of the root's derivations. + +## Breaking: a variant can no longer flag a root-declared field + +Under the old options accumulation, a variant could add `immutable: ["rootKey"]` +and tighten a field the root declared. There is no spelling for that now, and +the previous item is why: the only place a flag can be written is a field's +declaration, and the field is declared on the root. + +Move the flag to the root, where every variant inherits it — flags ride the +field-map spread, so a variant gets them with the fields. If two variants +genuinely need different flags on the same key, they are not sharing that field: +declare it separately on each variant and leave it off the root. + +Relaxing was never expressible and still is not: `immutable: []` did not widen +`updateInput` before, and there is no flag that reopens an inherited field now. diff --git a/CLAUDE.md b/CLAUDE.md index 1a03232..b97a60d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,12 +85,13 @@ cannot run against it. Measured — the reason is inline in ## Architecture -Twelve source modules under `packages/entity/src` besides `index.ts`, split by +Thirteen source modules under `packages/entity/src` besides `index.ts`, split by what they own: - **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from - one field map plus `generated` / `immutable` / `computed`, + one field map — the `generated` / `immutable` flags its entries carry — + plus `computed`, then returns a `Base` class carrying them as statics. `create` delegates to `make`; `update` delegates to `make`; every path funnels through `construct`, which runs `invariants` and seals the constructor call. Data @@ -99,7 +100,7 @@ what they own: `JSON.stringify`, or spread. `toJSON()` is the **only** public projection — it, `equals` and `update` all route through a module-private `project`, so there is no second public spelling of the same data. It also carries the - whole public surface: `Entity.computed` / `Entity.invariant` / + whole public surface: `Entity.field` / `Entity.computed` / `Entity.invariant` / `Entity.abstract` / `Entity.union` / `Entity.InvalidEntity` as expando properties, and every public type in a merged `declare namespace Entity`. Namespace members alias imported types @@ -120,11 +121,14 @@ 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. 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 + three; `base.spec.ts` pins them. A variant **accumulates** onto the root: + `invariants` concatenate, `computed` merges **per key**, and the flags need no + merging at all — they ride the field-map spread, wrapped, so a variant + inherits them with the fields. Relaxing is not expressible. **Redeclaring an + inherited field is forbidden**, flagged or not: a compile error naming + `FieldAlreadyDeclaredByTheRoot`, plus a declaration-time defect naming the + keys and the tag — a bare-schema redeclaration used to drop the root's flags + silently. 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`/ @@ -159,6 +163,19 @@ what they own: the members share and falls back to the empty type when they share none; `Plain` strips that root's abstractness, which a union could never implement. `Entity.Instance` is where the exact member union lives. +- **`field.ts`** — `field(schema, flags)`, public as `Entity.field`, and the + `FieldSpec` it returns: a plain `{ schema, flags }` record, never a proxy or a + subclass, because anything standing in front of an entity-class field breaks + `make`, which constructs through `this` (`TypeError: Ctor is not a +constructor` — measured). Two spellings in the signature are load bearing and + both are commented there: `flags` is intersected with a mapped rejection so a + misspelled key is a compile error (a constraint is not an excess-property + check — `{ generated: true, imutable: true }` compiled clean and left the + field mutable), and `schema` is **bare `T`**, never intersected with + `OnlyNominal`, because an intersection at an inference site broke zod's + `$ZodBranded` alias preservation across every branded field (measured, −874 B + over the billing fixture's emitted `.d.ts`). The nominal check lives at the + field map, which already unwraps `FieldSpec` through `SchemaOf`. - **`shape.ts`** — `OnlyNominal`, the type-level check rejecting unbranded fields, and `shape()`, which builds the validated field map. Both are internal; neither is exported from `index.ts`. @@ -197,26 +214,41 @@ design — `contract.spec.ts` pins that both ways. 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. 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 + 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. +- **The dead-end ledger: a key union in type-argument position cannot be + de-aliased.** `GeneratedKeys` / `ImmutableKeys` are computed **inside** + `EntityStatic` / `AbstractEntity` / `BaseInstance`, never passed as type + arguments, and the comment on `GeneratedKeys` in `types.ts` is the record. + In argument position the printer re-carries the whole field map at every + appearance — the spike measured **+57.8%** on the billing fixture's emitted + declarations, +104% on `index.d.ts` alone — and three attempts to make the + emitter write the alias instead all failed on **both** 7.0.2 and 5.9.3: an + alias annotation, a defaulted parameter plus `infer`, and a + mapped-object-plus-`keyof` indirection each reconstituted the alias through + union-origin tracking. The fix is **arity reduction**, not a better spelling: + `Entity.Static`, `Entity.Abstract`, + `Entity.BaseInstance`. Inside a body `S` prints by name and the map + appears once — measured at **+8.0%** total, ~90 B per flagged-field + appearance, with **zero** `GeneratedKeys<` / `ImmutableKeys<` in the emitted + `.d.ts` set. That grep is the acceptance test; do not move these into a + parameter list. - **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 - seal, `generated`/`immutable` rules, `computed`'s contextual typing) means + seal, the `generated`/`immutable` flags, the redeclaration forbid, + `computed`'s contextual typing) means updating the matching `@ts-expect-error` assertion. - **One concept, one name.** The surface is meant to stay small enough that the library can be "done". Resist convenience aliases. - **`index.ts` exports `Entity`, and nothing else you write against.** A bare `computed` or `union` is too generic to take from a consumer's import scope, - so everything hangs off the builder. The sole exception is the nine + so everything hangs off the builder. The sole exception is the ten declaration-emit names — `AbstractEntity`, `BaseInstance`, `ConstructionKey`, - `EntityStatic`, `EntityUnion`, `MergedComputed`, `MergedFields`, `Sealed`, - `UnionMember` — exported at the top + `EntityStatic`, `EntityUnion`, `FieldSpec`, `MergedComputed`, `MergedFields`, + `Sealed`, `UnionMember` — exported at the top level as well: a downstream library compiling with `declaration: true` emits the _underlying_ name, not the namespace path aliasing it, so hiding them fails the consumer pass with diff --git a/README.md b/README.md index 20c8325..76876cd 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,13 @@ const Instant = z.iso.datetime().brand("Instant"); const Upper = z.string().min(1).brand("Upper"); class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, @@ -94,7 +97,7 @@ await db.insert(org.toJSON()); const loaded = Organization.make(row).getOrThrow(); // 5. Update. Returns a NEW entity; invariants re-run; immutable fields are a -// compile error and are dropped at runtime if smuggled past it. +// compile error, and rejected at runtime if smuggled past it. const renamed = loaded.update({ name: nextName }).getOrThrow(); // 6. Respond. The four schema members are plain `ZodObject`s, so a contract @@ -120,8 +123,8 @@ Organization.make({ ...row, name: "" }).match({ | ------------- | ----------- | ------------------------------------------------------------------------ | | `input` | `ZodObject` | everything `make()` accepts | | `output` | `ZodObject` | stored state and response body | -| `createInput` | `ZodObject` | create request — `input` minus `generated` | -| `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | +| `createInput` | `ZodObject` | create request — `input` minus the `generated` fields | +| `updateInput` | `ZodObject` | update request — `output` minus the `immutable` fields, partial | | _the class_ | zod schema | parses to an instance; valid as a field, and anywhere zod takes a schema | | Entry point | Takes | For | @@ -131,10 +134,13 @@ Organization.make({ ...row, name: "" }).match({ | `entity.update(patch)` | a partial of the mutable fields | an update use case | | `entity.toJSON()` | — | the stored data, for a write or a response | +| Field flag | `Entity.field(schema, …)` | Meaning | +| ----------- | ------------------------- | ------------------------------------------------ | +| `generated` | `{ generated: true }` | the domain supplies this field, never the caller | +| `immutable` | `{ immutable: true }` | it never changes after creation | + | Option | Meaning | | ------------ | ----------------------------------------------------------------------- | -| `generated` | fields the domain supplies, never the caller | -| `immutable` | fields that never change after creation | | `computed` | fields derived from the declared ones, re-derived on every construction | | `invariants` | rules built with `Entity.invariant`; any failing rule rejects | diff --git a/docs/examples/billing-api.md b/docs/examples/billing-api.md index 8986862..dc84ee0 100644 --- a/docs/examples/billing-api.md +++ b/docs/examples/billing-api.md @@ -23,9 +23,9 @@ export const UpdateOrganizationBody = Organization.updateInput; export const OrganizationResponse = Organization.output; ``` -There is nothing to maintain here. `createInput` is the field map minus whatever -the entity declares `generated`; `updateInput` is it minus `immutable` and minus -the computed fields, every remaining key optional. Add a generated field to the +There is nothing to maintain here. `createInput` is the field map minus every +field flagged `generated`; `updateInput` is it minus the ones flagged +`immutable` and minus the computed fields, every remaining key optional. Add a generated field to the entity and the create body follows on its own — that is the omit list nobody had to write, and the spec asserts it by checking the generated JSON Schema has exactly `name` and `slug`. diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index 952a4b4..653f358 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -1,6 +1,6 @@ --- title: Billing domain example -description: Declaring entities — branded fields, generated/immutable/computed, invariants, nesting, abstract roots, unions and factories — in a runnable package. +description: Declaring entities — branded fields, the generated/immutable flags, computed, invariants, nesting, abstract roots, unions and factories — in a runnable package. --- # Billing domain @@ -48,10 +48,13 @@ satisfy the branded type, which is exactly the point. ```ts export class Organization extends Entity("Organization")( - { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrganizationId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { displayLabel: Entity.computed( DisplayLabel, @@ -72,11 +75,12 @@ export class Organization extends Entity("Organization")( } ``` -`generated` names what the domain produces rather than the caller, so those -fields drop out of `createInput`. `immutable` names what `update` refuses. -`computed` is re-derived on **every** construction path, so it cannot drift from -its sources — the spec checks that by renaming an organization and asserting the -label followed. +`Entity.field(schema, flags)` is how a field says more than its shape. +`generated` marks what the domain produces rather than the caller, so those +fields drop out of `createInput`; `immutable` marks what `update` refuses. +`name` carries neither, so it stays a bare schema. `computed` is re-derived on +**every** construction path, so it cannot drift from its sources — the spec +checks that by renaming an organization and asserting the label followed. Behaviour lives in the class body. This is a real class, not a record with functions bolted beside it. @@ -93,10 +97,12 @@ extended rather than instantiated: export abstract class BillingDocumentBase extends Entity.abstract( "BillingDocument", )( - { issuedTo: Organization, total: Money, issuedAt: Instant }, { - generated: ["issuedAt"], - immutable: ["issuedAt", "issuedTo"], + issuedTo: Entity.field(Organization, { immutable: true }), + total: Money, + issuedAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { period: Entity.computed(AccountingPeriod, (d) => d.issuedAt.slice(0, 7)), }, @@ -118,11 +124,21 @@ export abstract class BillingDocumentBase extends Entity.abstract( // index.ts export class Invoice extends BillingDocumentBase.extend("Invoice")( - { id: InvoiceId, kind: z.literal("INVOICE") /* … */ }, { - generated: ["id", "kind"], - immutable: ["id", "kind"], - /* … invariants, one of them */ + id: Entity.field(InvoiceId, { generated: true, immutable: true }), + kind: Entity.field(z.literal("INVOICE"), { + generated: true, + immutable: true, + }), + /* … lines, status, dunningReasons, level */ + }, + { + invariants: [ + Entity.invariant( + (d) => d.status !== "VOID" || d.dunningReasons.length === 0, + "a void invoice cannot be in dunning", + ), + ], }, ) { override signedAmount(): number { @@ -138,10 +154,13 @@ 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 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, +Note what the variants do **not** state. `Invoice` declares only the fields it +introduces: `issuedAt` is generated and `issuedAt`/`issuedTo` immutable because +the root's fields carry those flags, and the flags travel with the fields into +every variant. Restating one is not the way to keep it — a variant that named +`issuedAt` again would not compile at all +([why](/reference/declaration#a-variant-may-not-redeclare-an-inherited-field)). +The options accumulate root-then-variant, `computed` 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. @@ -218,8 +237,8 @@ which `emit-guards.ts` pins. The body holds statics only; the union has no instances of its own. `kind` is a **declared domain field** — `z.literal("INVOICE")` on one member and -`z.literal("CREDIT_NOTE")` on the other, both `generated` so no caller can supply -the wrong one. +`z.literal("CREDIT_NOTE")` on the other, both flagged `generated` so no caller +can supply the wrong one. It is tempting to reach for `_tag` here, since every entity has one. That does not work, and fails quietly rather than loudly: `_tag` is non-enumerable, so it diff --git a/docs/examples/index.md b/docs/examples/index.md index 081fe17..be7f3ef 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -22,7 +22,8 @@ pnpm test ## [Billing domain](/examples/billing-domain) -Declaring the entities: branded fields, `generated` / `immutable` / `computed`, +Declaring the entities: branded fields, the `generated` / `immutable` flags, +`computed`, invariants as values, one entity nested inside another, an abstract root with two variants gathered under a discriminated union, and factories binding the id and clock the package refuses to read for itself. diff --git a/docs/how-to/evolve-an-entity.md b/docs/how-to/evolve-an-entity.md index 9b2371b..ff99841 100644 --- a/docs/how-to/evolve-an-entity.md +++ b/docs/how-to/evolve-an-entity.md @@ -143,11 +143,13 @@ 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 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).) +Fields and options declared on the root are inherited, and a variant **adds** to +them: name only the fields and rules the variant itself introduces, and the +root's still apply. A field's `generated`/`immutable` flags travel with the +field, so nothing a root declared can be shed — and a variant may not restate an +inherited field at all, flagged or not, which is a compile error and a +declaration-time defect both. +([How each part merges](/reference/declaration#root-extend-tag-fields-options).) ## Computed fields heal themselves diff --git a/docs/how-to/http-contract.md b/docs/how-to/http-contract.md index e5fa1f8..ff05a71 100644 --- a/docs/how-to/http-contract.md +++ b/docs/how-to/http-contract.md @@ -23,14 +23,14 @@ from the model. ## Use the four `ZodObject` members directly ```ts -const CreateBody = Organization.createInput; // input minus generated -const UpdateBody = Organization.updateInput; // output minus immutable and computed, partial +const CreateBody = Organization.createInput; // input minus the generated fields +const UpdateBody = Organization.updateInput; // output minus the immutable and computed fields, partial const ResponseBody = Organization.output; // stored state ``` -Nothing to maintain: `createInput` drops whatever `generated` names, and -`updateInput` drops whatever `immutable` names plus the computed fields. Add a -generated field to the entity and the create body follows. +Nothing to maintain: `createInput` drops every field flagged `generated`, and +`updateInput` drops every field flagged `immutable` plus the computed ones. Add +a generated field to the entity and the create body follows. ## Convert to JSON Schema diff --git a/docs/how-to/model-an-aggregate.md b/docs/how-to/model-an-aggregate.md index 404a363..55603f2 100644 --- a/docs/how-to/model-an-aggregate.md +++ b/docs/how-to/model-an-aggregate.md @@ -42,6 +42,11 @@ class Order extends Entity("Order")({ }) {} ``` +A nested entity is a field like any other in the second sense too: it can carry +flags. `customer: Entity.field(Customer, { immutable: true })` makes the whole +nested entity unpatchable, and the billing example's root does exactly that with +its `issuedTo`. + `Order` is a real entity: invariants, deep immutability, `make`, `update`, `toJSON`. The nested entities keep everything that makes _them_ entities: diff --git a/docs/how-to/test-domain-logic.md b/docs/how-to/test-domain-logic.md index d410ce6..f7069ba 100644 --- a/docs/how-to/test-domain-logic.md +++ b/docs/how-to/test-domain-logic.md @@ -138,7 +138,7 @@ expect(trial.update({ trialEndsAt: earlier }).isErr()).toBe(true); ## Pin compile-time guarantees in `*.test-d.ts` Some behaviour only exists at the type level — the seal, the -`generated`/`immutable` rules, a computed field being underivable. Those belong +`generated`/`immutable` flags, a computed field being underivable. Those belong in a `.test-d.ts` file, checked by `tsc`: ```ts diff --git a/docs/index.md b/docs/index.md index 32bd372..50c04fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,7 +25,7 @@ hero: features: - icon: { src: /icons/schemas.svg } title: One declaration, four schemas - details: "input, output, createInput and updateInput are derived from one field map plus generated / immutable / computed. Plain ZodObjects, so they convert to JSON Schema in both directions — no hand-written omit lists." + details: "input, output, createInput and updateInput are derived from one field map — the generated / immutable flags a field carries, plus computed. Plain ZodObjects, so they convert to JSON Schema in both directions — no hand-written omit lists." - icon: { src: /icons/seal.svg } title: Sealed and immutable details: "new SomeEntity(…) does not compile. Every instance comes through make, update or a factory, so the invariants have run — and its data is deep-frozen, mutation a compile error first." @@ -50,10 +50,13 @@ const Instant = z.iso.datetime().brand("Instant"); const Upper = z.string().min(1).brand("Upper"); class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index 966a097..b999d21 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -1,11 +1,12 @@ --- title: Declaring an entity -description: Entity(tag)(fields, options), the field rules, the four options, and the Entity.computed / Entity.invariant / Entity.abstract / Entity.union declaration helpers. +description: Entity(tag)(fields, options), the field rules, the field flags, the two options, and the Entity.field / Entity.computed / Entity.invariant / Entity.abstract / Entity.union declaration helpers. --- # Declaring an entity -The builder itself, the rules a field map must satisfy, the four options, and the +The builder itself, the rules a field map must satisfy, the flags a field can +carry, the two options, and the helpers that go inside them. For _why_ it is shaped this way, see [Explanation](/explanation/why-entity); for task recipes, see the [how-to guides](/how-to/http-contract). @@ -34,7 +35,9 @@ this one entity goes in its own class body. ### `fields` -A map of field name to schema. Every field must be **nominal** — a branded +A map of field name to schema, or to +[`Entity.field(schema, flags)`](#entity-field-schema-flags) where the field +carries modifiers. Every field must be **nominal** — a branded schema, a narrow literal union, a boolean, or another entity class. A bare `z.string()` is a compile error naming `DomainFieldMustBeBrandedOrAnEntity`. ([Why](/explanation/branded-fields).) @@ -50,21 +53,75 @@ Four names are reserved, because an entity installs them on every instance: ### `options` -| Option | Type | Effect | -| ------------ | ---------------------------------- | ---------------------------------------------------------------------------------- | -| `generated` | `readonly (keyof fields)[]` | omitted from `createInput`; supplied by a factory's generators | -| `immutable` | `readonly (keyof output)[]` | omitted from `updateInput`; `update()` rejects them even if smuggled past the type | -| `computed` | `{ [name]: Entity.ComputedField }` | derived fields; added to `output`, re-derived on every construction | -| `invariants` | `readonly Entity.Invariant[]` | rules spanning two or more declared fields; any failing rule rejects | +| Option | Type | Effect | +| ------------ | ---------------------------------- | -------------------------------------------------------------------- | +| `computed` | `{ [name]: Entity.ComputedField }` | derived fields; added to `output`, re-derived on every construction | +| `invariants` | `readonly Entity.Invariant[]` | rules spanning two or more declared fields; any failing rule rejects | -`generated` and `immutable` are keyed off the field names, so a typo is a -compile error rather than a silently-inert entry. +Both are optional, and so is the whole object: an entity that declares neither +is `Entity("Note")({ … })` with one argument. + +There is no `generated` option and no `immutable` option. Both are **flags on +the field itself** — see [`Entity.field`](#entity-field-schema-flags). Writing +either key here is a compile error. `Entity.ComputedField` and `Entity.Invariant` are both generic; the parameters are elided above because you never write them. `Entity.computed` and `Entity.invariant` infer them from the surrounding declaration, which is what makes `d` contextually typed with no annotation. +## `Entity.field(schema, flags)` + +One field with modifiers, written in the field map where the schema would +otherwise go: + +```ts +class Organization extends Entity("Organization")({ + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, +}) {} +``` + +| Flag | Default | Effect | +| ----------- | ------- | -------------------------------------------------------------------------------------- | +| `generated` | `false` | drops the key from `createInput`; a factory's generators supply it instead | +| `immutable` | `false` | drops the key from `updateInput`; `update()` rejects it even if smuggled past the type | + +An unflagged field is a bare schema — `name` above. There is no third state: +both flags default to `false`, so `Entity.field(Slug, { immutable: true })` is +`generated: false`. + +The flags argument is **required**. The function exists to flag, so +`Entity.field(Slug, {})` is legal and does exactly nothing; write the bare +schema instead. + +A misspelled flag name is a compile error: + +```ts +Entity.field(Slug, { imutable: true }); // ✗ UnknownFlagIsRejected +Entity.field(Slug, { generated: true, imutable: true }); // ✗ same +``` + +The second line is the dangerous shape, and the reason the check exists. +A constraint is not an excess-property check: against `Partial` alone +that object compiled clean and the field was silently mutable — measured. Every +unknown key is mapped to `UnknownFlagIsRejected`, which is both the type and the +message. + +The nominal-field rule is enforced at the **field map**, not at this call. +`Entity.field(z.string(), { immutable: true })` type-checks on its own; placed +under a key it is rejected naming `DomainFieldMustBeBrandedOrAnEntity`, exactly +as a bare `z.string()` there would be. Checking the schema here as well cost +zod's `$ZodBranded` alias in consumers' emitted declarations: every branded +field expanded structurally instead, worth 874 bytes over the +[billing-domain](/examples/billing-domain) fixture's emitted `.d.ts` set, and +recovered when the redundant check was dropped. + +Flags ride their field. A variant extending a root inherits the root's fields +_with_ their flags, and cannot restate them +([below](#root-extend-tag-fields-options)). + ## `Entity.computed(schema, from)` One derived field: its schema, and the function producing it. @@ -127,10 +184,10 @@ A **root**: the fields and the behaviour several entities share, in a class that is extended rather than instantiated. ```ts -abstract class AccountBase extends Entity.abstract("Account")( - { id: AccountId, label: Label }, - { immutable: ["id"] }, -) { +abstract class AccountBase extends Entity.abstract("Account")({ + id: Entity.field(AccountId, { immutable: true }), + label: Label, +}) { abstract describe(): string; get slug(): string { @@ -140,8 +197,8 @@ abstract class AccountBase extends Entity.abstract("Account")( ``` `fields` and `options` are exactly what `Entity(tag)(…)` takes — same field -rules, same four options — and both are inherited by every entity extended from -the root. +rules, same flags, same two options — and all of it is inherited by every entity +extended from the root. A root is **not** an entity. It has no `make`, no `factory`, and none of the four schema members; those belong to a variant, which has a tag to build them @@ -261,44 +318,64 @@ A variant's declaration **accumulates** onto the root's, root-then-variant. It adds to what it inherits and cannot shed it, so it is never quietly laxer than its root. -| Declaration part | How a variant's declaration meets the root's | -| ---------------- | -------------------------------------------------------------- | -| `fields` | merged **per key** — a repeated key takes the variant's schema | -| `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 | +| Declaration part | How a variant's declaration meets the root's | +| ---------------- | ------------------------------------------------------------------------ | +| `fields` | **added** — a key the root already declares is a compile error | +| field flags | ride their fields; the merged map's flags are the union of the two maps' | +| `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. - -`fields` and `computed` merge per key rather than concatenating, because both -are maps. A variant can add to either beside what the root declared, and can -**redeclare** an entry the root declared — its schema, and for `computed` its -derivation, replace that entry alone — but cannot drop one. - -Redeclaring an inherited key, in either map, has one edge, measured. The -variant's schema is what validates and its derivation is what runs, and every -surface read off the declaration agrees: `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 key off -`Entity.Output` where its exact type matters. +inherits everything `AccountBase` declared, `id`'s `immutable` flag included. + +#### A variant may not redeclare an inherited field + +A key the root declares is the root's. Naming it again in a variant's field map +is a compile error, whether or not either spelling carries flags: + +```ts +AccountBase.extend("Clash")({ label: Label }); // ✗ FieldAlreadyDeclaredByTheRoot +AccountBase.extend("Clash2")({ + label: Entity.field(Label, { immutable: true }), // ✗ same +}); +``` + +The type-level rejection is backed by a runtime one, because a declaration +compiled from JavaScript or through a cast reaches the same place. It is a +**declaration-time defect** — thrown while the declaration is on the stack, the +same ruling as `Entity.union`'s duplicate discriminant — and it names the keys +and the tag: + +``` +Clash: field(s) "label" already declared by the root — a variant adds fields, +it does not redeclare them. +``` + +A consequence worth stating plainly: a variant cannot flag a root-declared +field **at all**. A field's flags live at its declaration site, and the only way +to make `label` immutable for `Personal` is to flag it on `AccountBase`, where +every variant gets it. + +Relaxing is not expressible either, and there is no spelling that asks for it. +`invariants: []` on a variant does not clear the root's rules — an empty list +contributes nothing, which is not the same as taking something away. + +`computed` is the one map a variant may still redeclare into, per key rather +than by concatenating: a variant can add entries beside the root's and can +replace one of the root's derivations, but cannot drop one. + +Redeclaring an inherited **computed** key has one edge, measured. The variant's +derivation is what runs, and every surface read off the declaration agrees: +`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 key 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/entry-points.md b/docs/reference/entry-points.md index 88f9a1b..92d7efd 100644 --- a/docs/reference/entry-points.md +++ b/docs/reference/entry-points.md @@ -18,8 +18,8 @@ There is no other: `new SomeEntity(…)` ## `SomeEntity.factory(generators)` → `(input) => Result` -Binds the `generated` fields' sources. Generators are **functions**, called -once per create. +Binds the sources of every field flagged `generated`. Generators are +**functions**, called once per create. ```ts const createOrg = Organization.factory({ @@ -35,7 +35,7 @@ Each generator returns its field schema's **input**, not the branded output: generated values go through `make`'s validation like any other data, so `() => crypto.randomUUID()` needs no cast. -An entity that declares no `generated` fields still has a factory: its +An entity that flags no field `generated` still has a factory: its generators map has no keys, so `{}` is what you pass. ```ts @@ -103,8 +103,9 @@ string is not a `Slug`, and this is the one form that says so at the call site. Returns a **new** entity. Re-runs the invariants and re-derives the computed fields. -The patch must contain only keys `updateInput` accepts. A key that is -`immutable`, `computed`, or not a field of the entity at all is **rejected** +The patch must contain only keys `updateInput` accepts. A key that is flagged +`immutable`, or is `computed`, or is not a field of the entity at all, is +**rejected** with an `InvalidEntity` carrying that key in `path` — every offending key reports, not just the first. They are absent from the patch type too, but the compile-time guard only fires on object literals: an adapter that builds its diff --git a/docs/reference/schemas.md b/docs/reference/schemas.md index 7c12f61..38ef354 100644 --- a/docs/reference/schemas.md +++ b/docs/reference/schemas.md @@ -17,8 +17,8 @@ Every entity carries four plain `ZodObject`s as statics, plus the class itself. ```ts Organization.input; // ZodObject — everything make() accepts Organization.output; // ZodObject — stored state and response body -Organization.createInput; // ZodObject — input minus generated -Organization.updateInput; // ZodObject — output minus immutable and computed, partial +Organization.createInput; // ZodObject — input minus the generated fields +Organization.updateInput; // ZodObject — output minus the immutable and computed fields, partial Organization.entityName; // the tag, as a literal type Organization; // …is itself a zod schema, parsing to an instance ``` diff --git a/docs/reference/types.md b/docs/reference/types.md index 1e08761..0d487e1 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -1,6 +1,6 @@ --- title: Helper types -description: Entity.Input, Entity.Output, Entity.CreateInput, Entity.Patch, Entity.Instance — and the nine declaration-emit names exported at the top level. +description: Entity.Input, Entity.Output, Entity.CreateInput, Entity.Patch, Entity.Instance — and the ten declaration-emit names exported at the top level. --- # Helper types @@ -38,21 +38,46 @@ union of entities. ## The other namespace members Also `Entity.ComputedField` and `Entity.Invariant`, the shapes `Entity.computed` -and `Entity.invariant` return; `Entity.Union`, what `Entity.union` returns; +and `Entity.invariant` return; `Entity.FieldSpec`, what `Entity.field` returns; +`Entity.Union`, what `Entity.union` returns; `Entity.Abstract`, what `Entity.abstract(name)(fields, options)` returns; and `Entity.Static`, the full static surface `Entity(tag)(fields, options)` returns — the type of the anonymous class the declaration form extends. You rarely name any of them: the declaration helpers infer their parameters from the surrounding declaration. +Three of them changed arity in the release that moved `generated`/`immutable` +onto the fields: + +| Type | Arity | Was | +| --------------------- | ----------------- | -------------------- | +| `Entity.Static` | `` | `` | +| `Entity.Abstract` | `` | `` | +| `Entity.BaseInstance` | `` | `` | + +Their top-level spellings moved with them: `EntityStatic` — the +one place `B` was already exposed, so it went from six parameters to four — +`AbstractEntity` and `BaseInstance`. + +The dropped parameters were the generated- and immutable-key unions. They are +computed inside each type's body from the flags `S` carries, and that is the +whole point: a key union standing in **argument position** cannot be de-aliased +by the emitter, so it re-serialises the entire field map at every appearance in +a consumer's `.d.ts`. Measured on the billing fixture, the naive spelling grew +the emitted declarations by 57.8%; computing the unions inside the bodies +instead leaves ~90 bytes per flagged-field appearance, +8.0% total, and no +`GeneratedKeys<` or `ImmutableKeys<` anywhere in the output. + ## The declaration-emit names -Nine types are exported at the top level: `AbstractEntity`, `BaseInstance`, -`ConstructionKey`, `EntityStatic`, `EntityUnion`, `MergedComputed`, -`MergedFields`, `Sealed`, `UnionMember`. They are the one exception to the -single-import rule, and none of them is part of the API you write against. Eight +Ten types are exported at the top level: `AbstractEntity`, `BaseInstance`, +`ConstructionKey`, `EntityStatic`, `EntityUnion`, `FieldSpec`, +`MergedComputed`, `MergedFields`, `Sealed`, `UnionMember`. They are the one +exception to the single-import rule, and none of them is part of the API you +write against. Nine also have namespace aliases for anyone annotating by hand — `Entity.Abstract`, -`Entity.BaseInstance`, `Entity.ConstructionKey`, `Entity.MergedComputed`, +`Entity.BaseInstance`, `Entity.ConstructionKey`, `Entity.FieldSpec`, +`Entity.MergedComputed`, `Entity.MergedFields`, `Entity.Sealed`, `Entity.Static`, `Entity.Union` — but a consumer's _emitted declarations_ use the top-level names. @@ -63,6 +88,7 @@ import type { ConstructionKey, EntityStatic, EntityUnion, + FieldSpec, MergedComputed, MergedFields, Sealed, @@ -85,6 +111,11 @@ top-level name. What each one buys was measured, not assumed: (240 bytes with the name), a realistic enum crossed the serialisation ceiling (`TS7056`, issue #31), and a branded object field expanded until zod's module-private `$brand` symbol could not be named (`TS4020`, #32). +- **`FieldSpec`** — what `Entity.field(schema, flags)` returns, and therefore + the declared type of every flagged field in a consumer's field map. Their + `.d.ts` has to name it. `emit-guards.ts` names it too: a namespace member + emitted as a circular self-alias still compiles, so only a fixture that walks + it catches the degradation. - **`AbstractEntity`** — the same story one declaration form over: a consumer writing `abstract class X extends Entity.abstract("X")(…) {}` emits the underlying name into its declarations, not the `Entity.Abstract` path that diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index cf4d9ea..ff45f50 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -85,22 +85,25 @@ differ. Declare that: ```ts -class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant }, - { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], - }, -) {} +class Organization extends Entity("Organization")({ + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), +}) {} ``` -- `generated` drops those fields from `createInput` — a create request cannot - carry them. -- `immutable` drops them from `updateInput` — and `update()` rejects them at - runtime even if something smuggles them past the type, so a change that +`Entity.field(schema, flags)` wraps a field that carries modifiers; `name`, +which carries none, stays a bare schema. + +- `generated` drops that field from `createInput` — a create request cannot + carry it. +- `immutable` drops it from `updateInput` — and `update()` rejects it at + runtime even if something smuggles it past the type, so a change that cannot happen is reported rather than quietly ignored. -Both are keyed off the field names, so a typo is a compile error rather than a +The flags sit on the field, so there is no second list to keep in step with the +field names, and a misspelled flag (`imutable`) is a compile error rather than a silently-inert entry. ## 4. Create one @@ -202,10 +205,13 @@ A single field's schema cannot express "these two fields must agree". ```ts class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { invariants: [ Entity.invariant( (d) => d.name.length <= 80, @@ -231,10 +237,13 @@ Two different things live in a class, and they go in two different places: const Upper = z.string().min(1).brand("Upper"); class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: DisplayName, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: DisplayName, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, @@ -266,7 +275,7 @@ org.name; // "Acme" — the original is unchanged renamed.equals(org); // false ``` -`org.update({ slug })` does not compile: `slug` is `immutable`. +`org.update({ slug })` does not compile: `slug` is flagged `immutable`. ## 10. Send it over the wire diff --git a/docs/typedoc.json b/docs/typedoc.json index 6589542..156e67e 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -12,13 +12,11 @@ "BehaviourOf", "ComputedField", "ComputedFieldSrc", - "ComputedOf", "ConstructedInstance", "ConstructionKeySrc", "CreateInputOf", "DeepReadonly", "DomainFieldMustBeBrandedOrAnEntity", - "Entry", "EntityFactory", "EntityStaticSrc", "EntityUnionSrc", @@ -36,6 +34,7 @@ "IsNominalField", "MergedComputedSrc", "MergedFieldsSrc", + "NoRedeclaredKeys", "OnlyNominal", "OutputOf", "PatchOf", @@ -45,6 +44,7 @@ "SchemasOf", "SealedSrc", "SharedBase", + "UnknownFlagIsRejected", "UpdateInputShapeOf" ] } diff --git a/packages/entity/README.md b/packages/entity/README.md index d4d0cbd..1e22b5c 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -29,10 +29,13 @@ const Instant = z.iso.datetime().brand("Instant"); const Upper = z.string().min(1).brand("Upper"); class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug, name: Name, createdAt: Instant }, { - generated: ["id", "createdAt"], - immutable: ["id", "createdAt", "slug"], + id: Entity.field(OrgId, { generated: true, immutable: true }), + slug: Entity.field(Slug, { immutable: true }), + name: Name, + createdAt: Entity.field(Instant, { generated: true, immutable: true }), + }, + { computed: { shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, @@ -55,13 +58,17 @@ const loaded = Organization.make(row).getOrThrow(); // rows, imports, event fold const renamed = loaded.update({ name: next }).getOrThrow(); // a NEW entity ``` -| Schema member | For | -| ------------- | ------------------------------------------------------------------- | -| `input` | everything `make()` accepts | -| `output` | stored state and response body | -| `createInput` | create request — `input` minus `generated` | -| `updateInput` | update request — `output` minus `immutable` and `computed`, partial | -| _the class_ | parses to an instance; valid as a field | +| Schema member | For | +| ------------- | ---------------------------------------------------------------------------- | +| `input` | everything `make()` accepts | +| `output` | stored state and response body | +| `createInput` | create request — `input` minus the `generated` fields | +| `updateInput` | update request — `output` minus the `immutable` and computed fields, partial | +| _the class_ | parses to an instance; valid as a field | + +`generated` and `immutable` are **flags on the field**, written with +`Entity.field(schema, flags)`; a field carrying neither is a bare schema. +`computed` and `invariants` are the two declaration options. An entity is **final**. Fields and behaviour shared by several entities go on a root, `Entity.abstract(name)(fields)`, and extension lives there; a union of From 3cc125b62ca3e87bdd589a5af709e7f75ece498e Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 18:07:43 +0200 Subject: [PATCH 7/8] fix: close final-review gaps in Entity.field and extend - base.ts: use Object.hasOwn instead of `in` for the redeclaration clash check, so a variant field named `constructor`/`toString` no longer trips a false "already declared" defect via the prototype chain - field.ts: reject a widened (non-literal) boolean flags value at the type level, closing a type/runtime divergence on generated/immutable - base.spec.ts, field.test-d.ts: pin both of the above, plus the deferred test that redeclaration through a behaviour-only intermediate root defects - CLAUDE.md: re-indent a misaligned continuation line --- CLAUDE.md | 4 ++-- packages/entity/src/base.spec.ts | 19 +++++++++++++++++++ packages/entity/src/base.ts | 6 +++++- packages/entity/src/field.test-d.ts | 7 +++++++ packages/entity/src/field.ts | 12 +++++++++++- 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b97a60d..6255535 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,8 +166,8 @@ what they own: - **`field.ts`** — `field(schema, flags)`, public as `Entity.field`, and the `FieldSpec` it returns: a plain `{ schema, flags }` record, never a proxy or a subclass, because anything standing in front of an entity-class field breaks - `make`, which constructs through `this` (`TypeError: Ctor is not a -constructor` — measured). Two spellings in the signature are load bearing and + `make`, which constructs through `this` (`TypeError: Ctor is not a constructor` + — measured). Two spellings in the signature are load bearing and both are commented there: `flags` is intersected with a mapped rejection so a misspelled key is a compile error (a constraint is not an excess-property check — `{ generated: true, imutable: true }` compiled clean and left the diff --git a/packages/entity/src/base.spec.ts b/packages/entity/src/base.spec.ts index df05e55..26a67bb 100644 --- a/packages/entity/src/base.spec.ts +++ b/packages/entity/src/base.spec.ts @@ -299,6 +299,25 @@ test("redeclaring an inherited field is a declaration-time defect", () => { ); }); +test("redeclaring a field through a behaviour-only intermediate root also defects", () => { + // `Auditable` adds no fields of its own — the clash is still against the root's. + expect(() => (Auditable.extend("Clash2") as (f: object) => unknown)({ label: Label })).toThrow( + /label.*already declared/, + ); +}); + +test("a field literally named toString is not a false clash — `in` walks the prototype chain", () => { + // Every plain object answers `"toString" in obj` truthily; only `Object.hasOwn` + // tells root-declared apart from Object.prototype-inherited. + class Described extends AccountBase.extend("Described")({ toString: Label }) { + override describe(): string { + return "described"; + } + } + const d = Described.make({ id, label: "Ada", toString: "hi" }).getOrThrow(); + expect(d.toString).toBe("hi"); +}); + test("a variant redefining one computed key overrides that entry only", () => { class Louder extends AccountBase.extend("Louder")( { note: Label }, diff --git a/packages/entity/src/base.ts b/packages/entity/src/base.ts index a3e7ebd..75d935c 100644 --- a/packages/entity/src/base.ts +++ b/packages/entity/src/base.ts @@ -77,7 +77,11 @@ const rebuild = ( ): { prototype: object } => { const parent = declarationOf(receiver); - const clashes = Object.keys(nextFields).filter((k) => parent !== undefined && k in parent.fields); + // `Object.hasOwn`, not `in`: `in` walks the prototype chain, so a field named + // `constructor` (legal — `NoRedeclaredKeys` allows it) tripped a false clash. + const clashes = Object.keys(nextFields).filter( + (k) => parent !== undefined && Object.hasOwn(parent.fields, k), + ); if (clashes.length > 0) { // A redeclared field is a bug in the declaration, not caller input. // Failing here names the key while the declaration is on the stack — diff --git a/packages/entity/src/field.test-d.ts b/packages/entity/src/field.test-d.ts index d6aac4f..364e9a5 100644 --- a/packages/entity/src/field.test-d.ts +++ b/packages/entity/src/field.test-d.ts @@ -46,6 +46,13 @@ test("a misspelled flag name is a compile error, not a silently-mutable field", Entity.field(Id, { generated: true, imutable: true }); }); +test("a widened boolean flag is a compile error, not a type/runtime split", () => { + const isGenerated: boolean = Math.random() > 0.5; + // @ts-expect-error a non-literal `boolean` would type `generated` as `false` + // regardless of the runtime value — only a `true`/`false` literal is accepted + Entity.field(Id, { generated: isGenerated }); +}); + test("the removed options are gone", () => { // @ts-expect-error `generated` is no longer an option — flag the field instead Entity("Gone")({ id: Id }, { generated: ["id"] }); diff --git a/packages/entity/src/field.ts b/packages/entity/src/field.ts index 51e66cc..0aff5cc 100644 --- a/packages/entity/src/field.ts +++ b/packages/entity/src/field.ts @@ -16,6 +16,9 @@ export type FieldSpec = { /** The rejection type for a misspelled flag name — named so it survives truncation and *is* the message, `shape.ts`'s trick. */ type UnknownFlagIsRejected = { readonly __unknownFlagIsRejected: never }; +/** A widened (non-literal) `boolean` satisfies `Partial` too, so it needs the same rejection — see `field()`'s flags comment. */ +type RejectWidenedBoolean = boolean extends V ? UnknownFlagIsRejected : V; + /** * Declares a field with modifiers, public as `Entity.field`: * @@ -47,7 +50,14 @@ export function field> // `{ generated: true, imutable: true }` satisfied `Partial` and // compiled clean — measured, and the misspelled field was silently mutable. // The intersection maps every unknown key to the rejection type instead. - flags: F & Record, UnknownFlagIsRejected>, + // The second mapped type closes a matching gap: `{ generated: someBoolean }` + // also satisfies `Partial` and widens `generated` to `false` at the + // type level while the runtime read would honour whatever `someBoolean` is + // — measured — so a non-literal `boolean` arm is rejected the same way. + flags: F & + Record, UnknownFlagIsRejected> & { + readonly [K in keyof F & keyof Flags]: RejectWidenedBoolean; + }, ): FieldSpec< T, { From fb04184985980f1886d6118a5f7271cb83315026 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 18:58:12 +0200 Subject: [PATCH 8/8] fix: harden isFieldSpec against prototype pollution, cite the measured typo diagnostic --- packages/entity/src/field.test-d.ts | 3 ++- packages/entity/src/field.ts | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/entity/src/field.test-d.ts b/packages/entity/src/field.test-d.ts index 364e9a5..6b1a87a 100644 --- a/packages/entity/src/field.test-d.ts +++ b/packages/entity/src/field.test-d.ts @@ -42,7 +42,8 @@ test("a misspelled flag name is a compile error, not a silently-mutable field", // @ts-expect-error "imutable" alone is rejected (TS2561 suggests the spelling) Entity.field(Id, { imutable: true }); // @ts-expect-error a typo beside a correct flag is the dangerous shape — excess-property - // checking alone lets it through, and the field would be silently mutable + // checking alone lets it through (measured: TS2322 naming UnknownFlagIsRejected, not TS2561), + // and without the rejection the field would be silently mutable Entity.field(Id, { generated: true, imutable: true }); }); diff --git a/packages/entity/src/field.ts b/packages/entity/src/field.ts index 0aff5cc..0bc511d 100644 --- a/packages/entity/src/field.ts +++ b/packages/entity/src/field.ts @@ -77,8 +77,18 @@ export function field> }; } -/** A field-map entry is a schema, or a schema with flags. */ +/** + * A field-map entry is a schema, or a schema with flags. The two positive + * checks are `Object.hasOwn` so a polluted `Object.prototype` cannot make an + * arbitrary object classify as a spec; the negative `_zod` check deliberately + * stays `in` — anything carrying zod's slot anywhere on its chain is + * schema-shaped, and excluding broadly is the safe direction. + */ export const isFieldSpec = (v: unknown): v is FieldSpec => - typeof v === "object" && v !== null && "schema" in v && "flags" in v && !("_zod" in v); + typeof v === "object" && + v !== null && + Object.hasOwn(v, "schema") && + Object.hasOwn(v, "flags") && + !("_zod" in v); export const schemaOf = (entry: unknown): unknown => (isFieldSpec(entry) ? entry.schema : entry);