From 3e4ea6bb36e43eeb90c3da23c2f90948a1003268 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 03:28:04 +0200 Subject: [PATCH 1/6] feat: type computed and generator returns as the schema's input --- packages/entity/src/computed.ts | 11 +++++-- packages/entity/src/entity.test-d.ts | 48 ++++++++++++++++++++++++++++ packages/entity/src/entity.ts | 5 +-- packages/entity/src/types.test-d.ts | 7 +--- packages/entity/src/types.ts | 39 ++++++++++------------ 5 files changed, 76 insertions(+), 34 deletions(-) diff --git a/packages/entity/src/computed.ts b/packages/entity/src/computed.ts index cc1f486..cb50a11 100644 --- a/packages/entity/src/computed.ts +++ b/packages/entity/src/computed.ts @@ -5,7 +5,12 @@ import type { OnlyNominal } from "./shape.js"; /** One derived field: its schema, and the function that produces it. */ export type ComputedField = { readonly schema: T; - readonly from: (d: D) => z.infer; + // `z.input`, not `z.infer`: the produced value goes straight to this + // schema's own parser on every construction path, so demanding the branded + // output only forced an `as` cast the parse then re-proved. The input form + // is castless and still rejects a wrong type; a branded return still + // assigns (brand ⊂ unbranded input), so pre-existing casts keep compiling. + readonly from: (d: D) => z.input; }; /** @@ -21,12 +26,12 @@ export type ComputedField = { * `from` reads the declared fields and re-runs on every construction, so a * derived value cannot go stale against its sources. `D` is fixed by the * expected type at the call site, so `d` needs no annotation, and the return - * type is checked against *this* field's schema — a wrong brand reports on the + * type is checked against *this* field's schema — a wrong type reports on the * field that produced it rather than on the whole map. */ export function computed( schema: T & OnlyNominal<{ value: T }>["value"], - from: (d: D) => z.infer, + from: (d: D) => z.input, ): ComputedField { return { schema: schema as T, from }; } diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index 777be55..a9d2d16 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -272,6 +272,54 @@ test("an entity is final: extend lives on an abstract root", () => { Final.extend("Extended")({}); }); +test("producers are castless: from and generators take the schema's input", () => { + const Upper = z.string().min(1).brand("Upper"); + const StampId = z.uuid().brand("StampId"); + + // a computed derivation needs no cast — its value is parsed on every + // construction path, so the type asks for the schema's input + class Shouty extends Entity("Shouty")( + { id: StampId, name: Slug }, + { + computed: { + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), + }, + }, + ) {} + + // a generator needs no cast either — its value goes through make + const createShouty = Shouty.factory({}); + void createShouty; + class Stamped extends Entity("Stamped")({ id: StampId, name: Slug }, { generated: ["id"] }) {} + const createStamped = Stamped.factory({ + id: () => crypto.randomUUID(), + }); + void createStamped; + + // the tie to the field's own schema survives the loosening + class Wrong extends Entity("Wrong")( + { id: StampId, name: Slug }, + { + computed: { + // @ts-expect-error a number is not the input of a string schema + shout: Entity.computed(Upper, (d) => d.name.length), + }, + }, + ) {} + void Wrong; + + // back-compat: a branded (cast) return still assigns — brand ⊂ input + class Legacy extends Entity("Legacy")( + { id: StampId, name: Slug }, + { + computed: { + shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer), + }, + }, + ) {} + void Legacy; +}); + test("the helper types name each shape", () => { const Instant = z.iso.datetime().brand("Instant"); class Org extends Entity("Org")( diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 262e3a3..6789e0d 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -191,8 +191,9 @@ export function Entity(tag: Tag) { * Only the derived values are checked, never the declared ones: those were * already validated against `input`, and re-running a field schema over * its own output is not a no-op — a non-idempotent transform applies twice, - * and a type-changing one rejects its own output. Checking the derived - * output is what makes `from`'s unchecked `as Brand` cast honest. + * and a type-changing one rejects its own output. This check is also why + * `from` may return the schema's plain `z.input`: the brand is applied by + * this parse, not demanded of the author. */ const computedParsers = computedFields.map( ([key, f]) => [key, f.from, fromSchema(f.schema)] as const, diff --git a/packages/entity/src/types.test-d.ts b/packages/entity/src/types.test-d.ts index 4bc827b..7c514a2 100644 --- a/packages/entity/src/types.test-d.ts +++ b/packages/entity/src/types.test-d.ts @@ -7,7 +7,6 @@ import type { OutputOf, InputOf, Fields, - GeneratedOf, PatchOf, Sealed, } from "./types.js"; @@ -56,14 +55,10 @@ test("OutputOf with no computed fields is the encoded object", () => { expectTypeOf().toEqualTypeOf>(); }); -test("CreateInputOf drops the generated fields, GeneratedOf keeps exactly them", () => { +test("CreateInputOf drops the generated fields", () => { type C = CreateInputOf; expectTypeOf().not.toHaveProperty("id"); expectTypeOf().toEqualTypeOf>(); - - type G = GeneratedOf; - expectTypeOf().toEqualTypeOf>(); - expectTypeOf().not.toHaveProperty("slug"); }); test("PatchOf is partial and drops the immutable fields", () => { diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index c878aad..8f104fe 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -47,23 +47,6 @@ export type OutputOf = InputOf & Computed /** What `create` accepts from a caller: everything the domain does not generate. */ export type CreateInputOf = Omit, G>; -/** - * What `create` requires the use case to supply. - * - * `Pick` constrains its second parameter to `keyof T`, and TypeScript cannot - * prove `G` satisfies `keyof InputOf` through zod's inference chain — which - * is also why `G`'s bound here is the bare `PropertyKey` rather than `keyof S`. - * The builders are what constrain the real call sites to `S`'s keys; this type - * only has to survive them. This mapped type with key remapping achieves - * the same semantics. `CreateInputOf` uses `Omit` (no such constraint); - * `GeneratedOf` uses this mapped form for that reason. The same unprovable - * subset relation is why `G` is a key union and not a tuple: the accumulating - * `readonly [...G, ...G2]` spelling is rejected with `TS2344`. - */ -export type GeneratedOf = { - [K in keyof InputOf as K extends G ? K : never]: InputOf[K]; -}; - /** * The types `DeepReadonly` hands back untouched. * @@ -368,7 +351,7 @@ export type EntityStatic< // `G`/`I` are unions of keys, not tuples. The tuple form cannot express the // merge: `readonly [...I, ...I2]` is rejected with `TS2344`, because // TypeScript will not prove the parent's key set is a subset of the child's - // through zod's inference chain. Measured — see `GeneratedOf` for the same + // through zod's inference chain. Measured — see `Generators` for the same // failure in its `Pick` form. G extends PropertyKey, I extends PropertyKey, @@ -432,16 +415,26 @@ export type EntityStatic< }; /** - * How a factory supplies each domain-generated field. Functions, never values: - * each is called once per `create`, so a factory built at the composition root - * yields a fresh id and timestamp every time. + * How a factory supplies each domain-generated field. Functions, never + * values: each is called once per `create`, so a factory built at the + * composition root yields a fresh id and timestamp every time. + * + * Each generator returns the field schema's **`z.input`**, not the parsed + * output (`InputOf` — despite its name — is `z.infer`, the branded shape): + * generated values are spread into `make`, which validates them like any + * other caller data, so demanding the branded form only forced an `as` cast + * that `make` then re-proved. + * + * Mapped with key remapping rather than `Pick`, because TypeScript cannot + * prove `G` satisfies `keyof S` through zod's inference chain — the builders + * constrain the real call sites, and this type only has to survive them. */ export type Generators = { - [K in keyof GeneratedOf]: () => GeneratedOf[K]; + [K in keyof S as K extends G ? K : never]: () => z.input; }; export type AsyncGenerators = { - [K in keyof GeneratedOf]: () => PromiseLike[K]>; + [K in keyof S as K extends G ? K : never]: () => PromiseLike>; }; /** From 62a1266a0f0050978f8d2476424d9a94eb16390a Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 03:33:52 +0200 Subject: [PATCH 2/6] test: drop the producer casts the input typing made redundant --- examples/billing-domain/src/index.ts | 9 ++++----- examples/billing-domain/src/organization.ts | 6 +----- examples/billing-domain/src/root.ts | 6 +----- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index fdc3de7..1ca3da8 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -27,7 +27,6 @@ import { Level, LineItem, } from "./vocabulary.js"; -import type { Instant, OrganizationId } from "./vocabulary.js"; export * from "./organization.js"; export * from "./root.js"; @@ -99,15 +98,15 @@ export class BillingDocument extends Entity.union("kind", [Invoice, CreditNote]) come in, bound once at the composition root — which is what leaves the entities themselves trivially testable. */ -const now = () => new Date().toISOString() as z.infer; +const now = () => new Date().toISOString(); export const createOrganization = Organization.factory({ - id: () => crypto.randomUUID() as z.infer, + id: () => crypto.randomUUID(), createdAt: now, }); export const createInvoice = Invoice.factory({ - id: () => crypto.randomUUID() as z.infer, + id: () => crypto.randomUUID(), issuedAt: now, // The discriminant is domain-generated, not caller-supplied: an invoice that // could be created claiming `kind: "CREDIT_NOTE"` would be a bug waiting to @@ -116,7 +115,7 @@ export const createInvoice = Invoice.factory({ }); export const createCreditNote = CreditNote.factory({ - id: () => crypto.randomUUID() as z.infer, + id: () => crypto.randomUUID(), issuedAt: now, kind: () => "CREDIT_NOTE" as const, }); diff --git a/examples/billing-domain/src/organization.ts b/examples/billing-domain/src/organization.ts index 1792d85..ac17a82 100644 --- a/examples/billing-domain/src/organization.ts +++ b/examples/billing-domain/src/organization.ts @@ -1,5 +1,4 @@ import { Entity } from "@btravstack/entity"; -import type { z } from "zod"; import { DisplayLabel, DisplayName, Instant, OrganizationId, Slug } from "./vocabulary.js"; @@ -18,10 +17,7 @@ export class Organization extends Entity("Organization")( generated: ["id", "createdAt"], immutable: ["id", "createdAt", "slug"], computed: { - displayLabel: Entity.computed( - DisplayLabel, - (d) => `${d.name} (${d.slug})` as z.infer, - ), + displayLabel: Entity.computed(DisplayLabel, (d) => `${d.name} (${d.slug})`), }, invariants: [ Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), diff --git a/examples/billing-domain/src/root.ts b/examples/billing-domain/src/root.ts index 79f0320..289a485 100644 --- a/examples/billing-domain/src/root.ts +++ b/examples/billing-domain/src/root.ts @@ -1,5 +1,4 @@ import { Entity } from "@btravstack/entity"; -import type { z } from "zod"; import { Organization } from "./organization.js"; import { AccountingPeriod, Instant, Money } from "./vocabulary.js"; @@ -43,10 +42,7 @@ export abstract class BillingDocumentBase extends Entity.abstract("BillingDocume generated: ["issuedAt"], immutable: ["issuedAt", "issuedTo"], computed: { - period: Entity.computed( - AccountingPeriod, - (d) => d.issuedAt.slice(0, 7) as z.infer, - ), + period: Entity.computed(AccountingPeriod, (d) => d.issuedAt.slice(0, 7)), }, invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], }, From cea11207f07bd11d0aed1f885ec3ae3c4009d6ce Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 03:48:21 +0200 Subject: [PATCH 3/6] docs: castless producers, mint helpers, and the typed entry for make --- .changeset/castless-producers.md | 25 +++++++++ README.md | 5 +- docs/examples/billing-domain.md | 11 ++-- docs/explanation/branded-fields.md | 79 +++++++++++++++++++++------- docs/how-to/model-an-aggregate.md | 5 +- docs/how-to/persist-and-rehydrate.md | 27 ++++++++++ docs/how-to/test-domain-logic.md | 46 ++++++++++++---- docs/index.md | 5 +- docs/reference/declaration.md | 4 +- docs/reference/entry-points.md | 47 +++++++++++++++++ docs/tutorial/getting-started.md | 16 +++--- packages/entity/README.md | 5 +- 12 files changed, 214 insertions(+), 61 deletions(-) create mode 100644 .changeset/castless-producers.md diff --git a/.changeset/castless-producers.md b/.changeset/castless-producers.md new file mode 100644 index 0000000..99c943b --- /dev/null +++ b/.changeset/castless-producers.md @@ -0,0 +1,25 @@ +--- +"@btravstack/entity": minor +--- + +Producer callbacks are now typed as their schema's **input**, so the cast they +all carried is gone: + +```ts +// before +shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer), +id: () => crypto.randomUUID() as z.infer, + +// after +shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), +id: () => crypto.randomUUID(), +``` + +Nothing changes at runtime: a computed value was always parsed by its own schema +on every construction path, and generated values always went through `make`'s +validation. The types now say so. Existing code compiles unchanged — a branded +return still assigns to its schema's input. + +One narrowing: a generator for a field that is both `.optional()` and +`generated` was an optional key and is now required (it may return `undefined`). +Declaring that combination is not known to occur anywhere. diff --git a/README.md b/README.md index 27f9913..20c8325 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,7 @@ class Organization extends Entity("Organization")( generated: ["id", "createdAt"], immutable: ["id", "createdAt", "slug"], computed: { - shout: Entity.computed( - Upper, - (d) => d.name.toUpperCase() as z.infer, - ), + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, invariants: [ Entity.invariant( diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index b60cb09..952a4b4 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -55,7 +55,7 @@ export class Organization extends Entity("Organization")( computed: { displayLabel: Entity.computed( DisplayLabel, - (d) => `${d.name} (${d.slug})` as z.infer, + (d) => `${d.name} (${d.slug})`, ), }, invariants: [ @@ -98,10 +98,7 @@ export abstract class BillingDocumentBase extends Entity.abstract( generated: ["issuedAt"], immutable: ["issuedAt", "issuedTo"], computed: { - period: Entity.computed( - AccountingPeriod, - (d) => d.issuedAt.slice(0, 7) as z.infer, - ), + period: Entity.computed(AccountingPeriod, (d) => d.issuedAt.slice(0, 7)), }, invariants: [ Entity.invariant( @@ -170,8 +167,8 @@ in — bound once, at the composition root: ```ts export const createOrganization = Organization.factory({ - id: () => crypto.randomUUID() as z.infer, - createdAt: () => new Date().toISOString() as z.infer, + id: () => crypto.randomUUID(), + createdAt: () => new Date().toISOString(), }); ``` diff --git a/docs/explanation/branded-fields.md b/docs/explanation/branded-fields.md index 5ee5b38..371dafd 100644 --- a/docs/explanation/branded-fields.md +++ b/docs/explanation/branded-fields.md @@ -1,6 +1,6 @@ --- title: Branded fields -description: Why every field must be nominal, what counts as nominal, why the compile error is a type name, and the two blessed ways to mint a branded value. +description: Why every field must be nominal, what counts as nominal, why the compile error is a type name, and where a branded value actually has to be minted. --- # Branded fields @@ -76,39 +76,78 @@ trick: `new SomeEntity(...)` fails on a missing property called `__useMakeOrFactoryInstead` ([Sealed construction](/explanation/sealed-construction)). -## The cost, and the two blessed patterns +## The cost, and where it falls The cost is ceremony: a branded type has no literal syntax, so somewhere a -plain value has to become a branded one. There are exactly two honest ways. +plain value has to become a branded one. The question is where — and the +answer is narrower than it first looks, because the two places you would most +expect to pay are the two that cost nothing. -**At a boundary, parse.** The schema is the brand's gatekeeper, so crossing -from untrusted to trusted goes through it — and for entity fields that -boundary already exists: `make` takes `unknown` and validates every field, so -a database row or request body never needs pre-branded values. +### Producers pay nothing -```ts -const slug = Slug.parse(raw); // z.infer — or safeParse, handled -``` +A factory generator and a `computed` derivation both hand a value to a +schema, not to a caller. A generated field is spread into `make`, which +validates it like any other data; a computed field's output is checked against +its own schema on every construction path. The parse already happens, and it +happens after the callback returns. -**Where the value is locally proven, cast.** Inside a generator or a -`computed` derivation the value is constructed in place and its validity is -visible in the same expression — and the package keeps the cast honest: -a factory's output goes through `make`'s validation, and a computed field's -output is checked against its own schema on every construction. +So both positions are typed as the schema's **input**, not its branded output. +A plain expression is already the right type: ```ts const createOrg = Organization.factory({ - id: () => crypto.randomUUID() as z.infer, + id: () => crypto.randomUUID(), }); computed: { - shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer), + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), } ``` -An `as` anywhere else — deep in application code, on a value that came from -outside — is not minting a brand, it is forging one: it silences the exact -check the rule exists to run. +Both callbacks once ended in a cast — ~~`crypto.randomUUID() as z.infer`~~ — that the parse immediately re-proved. Demanding the branded form +there bought nothing: a cast cannot make a value valid, only make its author +assert that it is. Dropping it weakens no check, because the check was never +the cast. A wrong _type_ still fails to compile; a wrong _value_ still fails to +parse. (Code still carrying the old cast compiles unchanged — a branded value +assigns to its own unbranded input.) + +### Everywhere else, parse through a mint helper + +Outside those two positions the brand has to be minted, and the schema is its +only gatekeeper. Crossing from untrusted to trusted therefore goes through the +schema, and for entity fields that crossing already exists: `make` takes +`unknown` and validates every field, so a database row or a request body never +needs pre-branded values at all. + +What is left is the code that writes values by hand — fixtures, seeds, +literals in a test — where a brand really does have to be minted one call at a +time. The spelling that keeps that readable is a helper declared beside the +vocabulary: + +```ts +const slug = (value: string) => Slug.parse(value); +const name = (value: string) => DisplayName.parse(value); +const money = (amount: number, currency: "EUR" | "USD" | "GBP") => + Money.parse({ amount, currency }); +``` + +`slug("acme")` then reads like the literal it replaces, and the schema stays +the only thing that decides whether the value is a `Slug`. A helper is a named +parse, not a cast: it can fail. + +Failing is also the one thing to know before reaching for one, because a helper +**throws**. That makes it right where a violation would be a bug in the code +that wrote it — a fixture, a seed, a literal — and wrong on anything that came +from outside the program. Untrusted data has its own entry point, and that one +returns a `Result` rather than throwing: `make`'s job, not a helper's. + +A cast reaches the same shape without any of that. On a literal you wrote two +lines up it is merely unchecked; on a value that came from outside — a field +plucked off a response body, a string threaded through three functions — it is +not minting a brand but forging one, silencing the exact check the rule exists +to run. The helper costs one line and never has to be re-audited for which of +those two it is. ## Related diff --git a/docs/how-to/model-an-aggregate.md b/docs/how-to/model-an-aggregate.md index ae00f60..176051f 100644 --- a/docs/how-to/model-an-aggregate.md +++ b/docs/how-to/model-an-aggregate.md @@ -26,10 +26,7 @@ class Customer extends Entity("Customer")( { id: CustomerId, name: Name }, { computed: { - shout: Entity.computed( - Upper, - (d) => d.name.toUpperCase() as z.infer, - ), + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, }, ) {} diff --git a/docs/how-to/persist-and-rehydrate.md b/docs/how-to/persist-and-rehydrate.md index 13018d6..5f406af 100644 --- a/docs/how-to/persist-and-rehydrate.md +++ b/docs/how-to/persist-and-rehydrate.md @@ -62,6 +62,33 @@ unchanged: Organization.make(org.toJSON()); // ✓ round-trips ``` +## Check a hand-built row at the call site + +`make` takes `unknown`, so a row you assemble yourself gets no compile-time +check. `satisfies Entity.Input<…>` restores it: + +```ts +const id = (value: string) => OrgId.parse(value); +const slug = (value: string) => Slug.parse(value); +const name = (value: string) => DisplayName.parse(value); +const at = (value: string) => Instant.parse(value); + +const row = { + id: id("0199b1f4-1b1e-7000-8000-000000000000"), + slug: slug("acme"), + name: name("Acme SA"), + createdAt: at("2026-08-06T09:00:00.000Z"), +} satisfies Entity.Input; + +const seeded = Organization.make(row).getOrThrow(); +``` + +Use it where you write the row — a seed, a migration, a fixture. A driver +handing you `unknown` needs nothing: there is no literal to check, and `make` +validates it either way. The values have to be branded, which is what the mint +helpers are for +([Branded fields](/explanation/branded-fields#everywhere-else-parse-through-a-mint-helper)). + ## Computed columns heal themselves Store computed fields if you need to index or query them — `toJSON()` includes diff --git a/docs/how-to/test-domain-logic.md b/docs/how-to/test-domain-logic.md index e5958e4..302b777 100644 --- a/docs/how-to/test-domain-logic.md +++ b/docs/how-to/test-domain-logic.md @@ -16,15 +16,38 @@ tests without stubbing `Date.now` or `crypto.randomUUID`. > import { Entity } from "@btravstack/entity"; > ``` +## Mint fixture values with a helper, not a cast + +Every field is branded, so a bare `"acme"` is not a `Slug` and a test that +passes one does not compile. Declare one helper per piece of vocabulary, beside +the vocabulary, and the rest of the file reads like literals: + +```ts +const slug = (value: string) => Slug.parse(value); +const name = (value: string) => DisplayName.parse(value); +const money = (amount: number, currency: "EUR" | "USD" | "GBP") => + Money.parse({ amount, currency }); + +createOrg({ slug: "acme", name: "Acme" }); // ✗ compile error — not branded +createOrg({ slug: slug("acme"), name: name("Acme") }); // ✓ +``` + +A helper is a named `parse`, not a cast — an invalid fixture fails loudly +instead of being asserted into existence. It throws, which is what you want +here: a bad literal in a test is a bug in the test. Untrusted data goes through +`make` instead and comes back as a `Result`. + +One thing worth knowing while you read a test file: `vitest` transpiles without +type-checking, so a branding violation is invisible to `vitest run`. Only `tsc` +sees it — which is why this package's own example compiles its declarations. + ## Bind fixed generators instead of stubbing globals The entity generates nothing itself, so a test binds its own sources: ```ts -const FIXED_ID = "0199b1f4-1b1e-7000-8000-000000000000" as z.infer< - typeof OrgId ->; -const FIXED_AT = "2026-08-06T09:00:00Z" as z.infer; +const FIXED_ID = "0199b1f4-1b1e-7000-8000-000000000000"; +const FIXED_AT = "2026-08-06T09:00:00Z"; const createOrg = Organization.factory({ id: () => FIXED_ID, @@ -32,11 +55,17 @@ const createOrg = Organization.factory({ }); test("a new organization starts on its trial", () => { - const org = createOrg({ slug, name }).getOrThrow(); + const org = createOrg({ + slug: slug("acme"), + name: name("Acme"), + }).getOrThrow(); expect(org.createdAt).toBe(FIXED_AT); }); ``` +The generators need no cast: a generated value is spread into `make` and +validated there, so each one is typed as its schema's input. + No global patching, no module mocking, no reset in `afterEach`. Production binds the same factory to real ports at the composition root. @@ -45,10 +74,7 @@ Need distinct ids across a test? Generators are called once per create: ```ts let n = 0; const createOrg = Organization.factory({ - id: () => - `0199b1f4-1b1e-7000-8000-${String((n += 1)).padStart(12, "0")}` as z.infer< - typeof OrgId - >, + id: () => `0199b1f4-1b1e-7000-8000-${String((n += 1)).padStart(12, "0")}`, createdAt: () => FIXED_AT, }); ``` @@ -129,4 +155,4 @@ guarantee is lost. One trap worth knowing: do not use `as never` for the values in such a test. `never` is assignable to _anything_, including a function type, so an assertion written with it can silently stop testing what you meant. Use real branded -values. +values — the mint helpers above are exactly what they are for. diff --git a/docs/index.md b/docs/index.md index cec8042..32bd372 100644 --- a/docs/index.md +++ b/docs/index.md @@ -55,10 +55,7 @@ class Organization extends Entity("Organization")( generated: ["id", "createdAt"], immutable: ["id", "createdAt", "slug"], computed: { - shout: Entity.computed( - Upper, - (d) => d.name.toUpperCase() as z.infer, - ), + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, invariants: [ Entity.invariant( diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index 285788e..f6b04d0 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -71,8 +71,8 @@ One derived field: its schema, and the function producing it. ```ts computed: { - fullName: Entity.computed(FullName, (d) => `${d.first} ${d.last}` as z.infer), - initials: Entity.computed(Initials, (d) => `${d.first[0]}${d.last[0]}` as z.infer), + fullName: Entity.computed(FullName, (d) => `${d.first} ${d.last}`), + initials: Entity.computed(Initials, (d) => `${d.first[0]}${d.last[0]}`), } ``` diff --git a/docs/reference/entry-points.md b/docs/reference/entry-points.md index e0e934f..068e200 100644 --- a/docs/reference/entry-points.md +++ b/docs/reference/entry-points.md @@ -31,6 +31,23 @@ createOrg({ slug, name }); // Result Pass an arrow, not a bare method reference — `{ id: ids.next }` loses `this`. +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 +generators map has no keys, so `{}` is what you pass. + +```ts +class Note extends Entity("Note")({ id: NoteId, label: Label }) {} + +const createNote = Note.factory({}); +createNote({ id, label }); // Result +``` + +That call is the fully-typed way in for such an entity — every caller field is +named and type-checked, where `Note.make(data)` takes `unknown`. + ## `SomeEntity.factoryAsync(generators)` → `(input) => AsyncResult` The same for promise-returning generators — an id from a database sequence, @@ -51,6 +68,36 @@ The only way in. Validates against `input`, re-derives the computed fields, checks the invariants, constructs. Extra keys are ignored, so a stored row carrying computed columns round-trips. +`data` is `unknown`, which is what lets a driver's row in without a cast — and +it means the compiler checks nothing at the call site. `Entity.Input` +names the shape `make` accepts, so a hand-written literal can opt back into the +full check with `satisfies`: + +```ts +const slug = (value: string) => Slug.parse(value); +const name = (value: string) => DisplayName.parse(value); +const id = (value: string) => OrgId.parse(value); +const at = (value: string) => Instant.parse(value); + +const row = { + id: id("0199b1f4-1b1e-7000-8000-000000000000"), + slug: slug("acme"), + name: name("Acme SA"), + createdAt: at("2026-08-06T09:00:00.000Z"), +} satisfies Entity.Input; + +const org = Organization.make(row); // Result +``` + +`satisfies` rather than a type annotation, so `row` keeps its literal type and +stays usable as itself. + +`Entity.Input` is the **parsed, branded** shape, so the values must be branded +too — which is why the helpers above are part of the pattern rather than +decoration. Written with bare literals (`slug: "acme"`), the same object fails +on every branded field. That failure is the brand doing its job: an unbranded +string is not a `Slug`, and this is the one form that says so at the call site. + ## `entity.update(patch)` → `Result` Returns a **new** entity. Re-runs the invariants and re-derives the computed diff --git a/docs/tutorial/getting-started.md b/docs/tutorial/getting-started.md index 4537f74..bb8d371 100644 --- a/docs/tutorial/getting-started.md +++ b/docs/tutorial/getting-started.md @@ -103,8 +103,8 @@ once — at your composition root, next to the ports you already have: ```ts const createOrganization = Organization.factory({ - id: () => crypto.randomUUID() as z.infer, - createdAt: () => new Date().toISOString() as z.infer, + id: () => crypto.randomUUID(), + createdAt: () => new Date().toISOString(), }); ``` @@ -125,6 +125,13 @@ Generators are **functions**, called once per create — so a factory built at startup still yields a fresh id per entity. And a test can bind fixed generators instead of stubbing globals. ([Why no I/O](/explanation/no-io).) +Note the asymmetry between the two blocks. A generator hands its value to the +entity, which validates it, so `crypto.randomUUID()` needs nothing; a caller +field is a branded value you are supplying, so it has to be minted. The cast +above is the shortest spelling for a tutorial — real code declares a helper per +piece of vocabulary and writes `slug("acme")` +([Branded fields](/explanation/branded-fields#everywhere-else-parse-through-a-mint-helper)). + ::: tip `getOrThrow()` is for a tutorial It is the shortest way to get at a value while you are exploring. Real code handles the `Result` — [step 6](#_6-handle-failure-as-a-value) does. @@ -222,10 +229,7 @@ class Organization extends Entity("Organization")( generated: ["id", "createdAt"], immutable: ["id", "createdAt", "slug"], computed: { - shout: Entity.computed( - Upper, - (d) => d.name.toUpperCase() as z.infer, - ), + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, }, ) { diff --git a/packages/entity/README.md b/packages/entity/README.md index c5fbe81..d4d0cbd 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -34,10 +34,7 @@ class Organization extends Entity("Organization")( generated: ["id", "createdAt"], immutable: ["id", "createdAt", "slug"], computed: { - shout: Entity.computed( - Upper, - (d) => d.name.toUpperCase() as z.infer, - ), + shout: Entity.computed(Upper, (d) => d.name.toUpperCase()), }, }, ) { From b779037e13a5169f2d1b88d1cf2f0ca04617972b Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 03:58:18 +0200 Subject: [PATCH 4/6] docs: fix the persist-and-rehydrate satisfies fence and pin the anchor trap --- CLAUDE.md | 7 +++++++ docs/how-to/persist-and-rehydrate.md | 15 +++++++-------- docs/how-to/test-domain-logic.md | 5 ++--- docs/reference/entry-points.md | 18 +++++++++--------- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 412eef4..6cee9ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,13 @@ Its build is `typedoc && vitepress build`. TypeDoc reads which is git-ignored and regenerated every build — `docs/api/index.md` is the one hand-written page under `api/`. +**An em dash in a heading you link to ships a dead anchor, and the build stays +green.** VitePress's dead-link check does not validate anchors, and its +slugifier replaces ASCII punctuation but passes U+2014 through — measured: +`### Everywhere else, parse — through a mint helper` emitted +`id="everywhere-else-parse-—-through-a-mint-helper"`, which no natural link +spelling matches. Keep em dashes out of linked headings. + TypeDoc runs from **`docs/`** rather than from `packages/entity/` (where the other btravstack repos put it), with its own TypeScript from the named `typedoc` catalog. That is forced, not stylistic: the default catalog's diff --git a/docs/how-to/persist-and-rehydrate.md b/docs/how-to/persist-and-rehydrate.md index 5f406af..f2cf034 100644 --- a/docs/how-to/persist-and-rehydrate.md +++ b/docs/how-to/persist-and-rehydrate.md @@ -68,21 +68,20 @@ Organization.make(org.toJSON()); // ✓ round-trips check. `satisfies Entity.Input<…>` restores it: ```ts -const id = (value: string) => OrgId.parse(value); +const orgId = (value: string) => OrgId.parse(value); const slug = (value: string) => Slug.parse(value); -const name = (value: string) => DisplayName.parse(value); -const at = (value: string) => Instant.parse(value); -const row = { - id: id("0199b1f4-1b1e-7000-8000-000000000000"), +const seedRow = { + id: orgId("0199b1f4-1b1e-7000-8000-000000000000"), slug: slug("acme"), - name: name("Acme SA"), - createdAt: at("2026-08-06T09:00:00.000Z"), } satisfies Entity.Input; -const seeded = Organization.make(row).getOrThrow(); +const seeded = Organization.make(seedRow).getOrThrow(); ``` +A key the entity does not declare is now a compile error rather than a value +`make` quietly ignores. + Use it where you write the row — a seed, a migration, a fixture. A driver handing you `unknown` needs nothing: there is no literal to check, and `make` validates it either way. The values have to be branded, which is what the mint diff --git a/docs/how-to/test-domain-logic.md b/docs/how-to/test-domain-logic.md index 302b777..5370cbf 100644 --- a/docs/how-to/test-domain-logic.md +++ b/docs/how-to/test-domain-logic.md @@ -8,12 +8,10 @@ description: Deterministic entity tests without stubbing Date.now or crypto.rand **Problem:** entities involve ids and timestamps, and you want deterministic tests without stubbing `Date.now` or `crypto.randomUUID`. -> Snippets below assume these imports: +> Snippets below assume this import: > > ```ts -> import { z } from "zod"; > import { P } from "unthrown"; -> import { Entity } from "@btravstack/entity"; > ``` ## Mint fixture values with a helper, not a cast @@ -28,6 +26,7 @@ const name = (value: string) => DisplayName.parse(value); const money = (amount: number, currency: "EUR" | "USD" | "GBP") => Money.parse({ amount, currency }); +// createOrg is the factory bound in the next section createOrg({ slug: "acme", name: "Acme" }); // ✗ compile error — not branded createOrg({ slug: slug("acme"), name: name("Acme") }); // ✓ ``` diff --git a/docs/reference/entry-points.md b/docs/reference/entry-points.md index 068e200..88f9a1b 100644 --- a/docs/reference/entry-points.md +++ b/docs/reference/entry-points.md @@ -74,19 +74,19 @@ names the shape `make` accepts, so a hand-written literal can opt back into the full check with `satisfies`: ```ts -const slug = (value: string) => Slug.parse(value); -const name = (value: string) => DisplayName.parse(value); -const id = (value: string) => OrgId.parse(value); -const at = (value: string) => Instant.parse(value); +const orgId = (value: string) => OrgId.parse(value); +const orgSlug = (value: string) => Slug.parse(value); +const orgName = (value: string) => DisplayName.parse(value); +const orgCreatedAt = (value: string) => Instant.parse(value); const row = { - id: id("0199b1f4-1b1e-7000-8000-000000000000"), - slug: slug("acme"), - name: name("Acme SA"), - createdAt: at("2026-08-06T09:00:00.000Z"), + id: orgId("0199b1f4-1b1e-7000-8000-000000000000"), + slug: orgSlug("acme"), + name: orgName("Acme SA"), + createdAt: orgCreatedAt("2026-08-06T09:00:00.000Z"), } satisfies Entity.Input; -const org = Organization.make(row); // Result +Organization.make(row); // Result ``` `satisfies` rather than a type annotation, so `row` keeps its literal type and From b430ee5e3ebcc58b50ed367ae658f07c44834bcb Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 04:10:12 +0200 Subject: [PATCH 5/6] test: pin the optional-generated narrowing and label the typed-entry guard --- packages/entity/src/entity.test-d.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index a9d2d16..9c31054 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -287,9 +287,12 @@ test("producers are castless: from and generators take the schema's input", () = }, ) {} - // a generator needs no cast either — its value goes through make + // no generated fields means the factory's typed entry is the empty map, + // the docs' `factory({})` claim const createShouty = Shouty.factory({}); void createShouty; + + // a generator needs no cast either — its value goes through make class Stamped extends Entity("Stamped")({ id: StampId, name: Slug }, { generated: ["id"] }) {} const createStamped = Stamped.factory({ id: () => crypto.randomUUID(), @@ -318,6 +321,17 @@ test("producers are castless: from and generators take the schema's input", () = }, ) {} void Legacy; + + // 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"] }, + ) {} + // @ts-expect-error `theField`'s generator is required even though the field is optional + Optional.factory({}); + Optional.factory({ theField: () => undefined }); }); test("the helper types name each shape", () => { From 3e45aace6c66e372ae2a70e6bcfb5ea20dfe68d3 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Sun, 9 Aug 2026 11:22:19 +0200 Subject: [PATCH 6/6] docs: stop implying the test page's import callout is complete --- docs/how-to/test-domain-logic.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/how-to/test-domain-logic.md b/docs/how-to/test-domain-logic.md index 5370cbf..d410ce6 100644 --- a/docs/how-to/test-domain-logic.md +++ b/docs/how-to/test-domain-logic.md @@ -8,11 +8,15 @@ description: Deterministic entity tests without stubbing Date.now or crypto.rand **Problem:** entities involve ids and timestamps, and you want deterministic tests without stubbing `Date.now` or `crypto.randomUUID`. -> Snippets below assume this import: +> Snippets below run in a vitest test file — `test` and `expect` in scope — +> plus: > > ```ts > import { P } from "unthrown"; > ``` +> +> Branded vocabulary (`Slug`, `Organization`, `createOrg`, …) is whatever your +> own domain declares; the sections below build the helpers from it. ## Mint fixture values with a helper, not a cast