From 983279a2d4c549948d96e100e4a179a9b5880c27 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 23:18:30 +0200 Subject: [PATCH 1/6] feat(examples): add a billing-domain example package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two entities and the vocabulary they are built from: branded fields, a branded Money value object, generated/immutable/computed, invariants as values, nesting, a union and factories — with specs, and the two declaration-emit passes that will take over from packages/entity/consumer/. Writing it immediately found a real bug: an exported const holding Entity.union(...) failed with TS4023 on $brand, fixed separately. The dunning vocabulary is held at thirty members deliberately; the README and emit-guards.ts say why, since it is the sort of thing a reader would otherwise helpfully trim. --- examples/README.md | 40 +++++ examples/billing-domain/README.md | 48 ++++++ examples/billing-domain/package.json | 30 ++++ examples/billing-domain/src/index.spec.ts | 91 +++++++++++ examples/billing-domain/src/index.ts | 169 +++++++++++++++++++++ examples/billing-domain/tsconfig.emit.json | 10 ++ examples/billing-domain/tsconfig.json | 8 + examples/billing-domain/vitest.config.ts | 9 ++ pnpm-lock.yaml | 34 +++++ pnpm-workspace.yaml | 1 + 10 files changed, 440 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/billing-domain/README.md create mode 100644 examples/billing-domain/package.json create mode 100644 examples/billing-domain/src/index.spec.ts create mode 100644 examples/billing-domain/src/index.ts create mode 100644 examples/billing-domain/tsconfig.emit.json create mode 100644 examples/billing-domain/tsconfig.json create mode 100644 examples/billing-domain/vitest.config.ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5d3dcc1 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,40 @@ +# Examples + +Three small packages modelling one billing domain, each showing a different job +`@btravstack/entity` does. + +📖 **[Annotated walkthroughs →](https://btravstack.github.io/entity/examples/)** + +| Package | Shows | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| [`billing-domain`](./billing-domain) | Declaring entities: branded fields, `generated` / `immutable` / `computed`, invariants, nesting, unions, factories. | +| [`billing-api`](./billing-api) | Composing the four plain `ZodObject`s into an HTTP contract and JSON Schema. | +| [`billing-persistence`](./billing-persistence) | Storing and rehydrating: `toJSON()` out, `make()` back. | + +Unlike the snippets in the guide, **this code compiles and is covered by +tests**. There is no broker, no database and no server to start: + +```sh +pnpm install +pnpm test # every example's specs +pnpm typecheck # includes the declaration-emit passes +``` + +## Why `billing-domain` compiles its own declarations + +It is not only an example. It is also the fixture proving a **downstream library +can build against this package** — the case where a consumer sets +`declaration: true` and TypeScript has to write entity's types into its own +`.d.ts`. + +That pass runs twice, on two TypeScript versions, and the second one is the +point: the repo builds with the 7.x native port, which does not enforce the 5.x +ceiling on serialised type length. Two declaration-emit bugs shipped through +that blind spot ([#31], [#32]) while every other check stayed green. + +`billing-domain/src/emit-guards.ts` carries the assertions that have no runtime +moment — the construction seal, a forged construction key, every namespace +member. It is a test, not a pattern to copy, and it says so. + +[#31]: https://github.com/btravstack/entity/issues/31 +[#32]: https://github.com/btravstack/entity/issues/32 diff --git a/examples/billing-domain/README.md b/examples/billing-domain/README.md new file mode 100644 index 0000000..87dc9ac --- /dev/null +++ b/examples/billing-domain/README.md @@ -0,0 +1,48 @@ +# billing-domain + +The modelling half of the example: two entities and the vocabulary they are +built from. + +```sh +pnpm --filter @btravstack/entity-example-billing-domain test +pnpm --filter @btravstack/entity-example-billing-domain typecheck +``` + +## What it shows + +`src/index.ts`, top to bottom: + +- **Branded fields.** Every data field is branded, so an `OrganizationId` and a + `Slug` stop being interchangeable strings. A bare `z.string()` is a compile + error — that is the guard, not an inconvenience. +- **`Money` as a branded object.** A value object with no identity: branded + rather than made an entity. Amounts are integer minor units, because binary + floats are the wrong tool for money. +- **`generated` / `immutable` / `computed`.** `generated` drops fields out of + `createInput`; `immutable` is what `update` refuses; `computed` is re-derived + on every construction path, so it cannot drift from its sources. +- **Invariants as values.** A broken rule comes back as an `InvalidEntity` + `Result`, never an exception. +- **Nesting.** `Invoice.issuedTo` is an `Organization` — the class is itself a + zod schema, so it parses back to a real instance with its behaviour intact. +- **A union**, dispatching on the declared discriminant so a failing member + reports its own issues. +- **Factories.** The package reads no clock and generates no id; a factory is + where those come in, bound once. That is what leaves the entities trivially + testable. + +## Two things that look odd on purpose + +**`DunningReason` has thirty members.** Vocabularies that wide are ordinary in +billing, and this one is held at full width because it pins [#31]: `TS7056` is a +threshold on serialised _characters_, so trimming it puts the example back under +the ceiling where it compiles and guards nothing. + +**`src/emit-guards.ts` is not example code.** It holds compile-time assertions +that have no runtime moment — construction staying sealed, a construction key +that cannot be forged, every `Entity.*` namespace member named so declaration +emit walks it. Its header explains the rules; the short version is that an +**unused** `@ts-expect-error` in that file is a failure, not noise. Do not copy +anything out of it. + +[#31]: https://github.com/btravstack/entity/issues/31 diff --git a/examples/billing-domain/package.json b/examples/billing-domain/package.json new file mode 100644 index 0000000..9e3c160 --- /dev/null +++ b/examples/billing-domain/package.json @@ -0,0 +1,30 @@ +{ + "name": "@btravstack/entity-example-billing-domain", + "private": true, + "description": "A small billing domain, modelled with @btravstack/entity", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json" + }, + "dependencies": { + "@btravstack/entity": "workspace:*", + "unthrown": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/standard-schema": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "typescript-consumer": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts new file mode 100644 index 0000000..13c825c --- /dev/null +++ b/examples/billing-domain/src/index.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "vitest"; + +import { + DisplayName, + Invoice, + Money, + Organization, + Slug, + createInvoice, + createOrganization, +} from "./index.js"; + +/** + * Every field here is branded, so a plain string or object literal does not + * satisfy its type — that is the whole point of branding. `parse` is how you + * mint one, and it is why these helpers exist rather than inline literals. + * + * Worth knowing while reading: this file passed `vitest` before it typechecked. + * vitest transpiles without checking types, so branding violations are invisible + * to it — which is exactly why this package also compiles its own declarations. + */ +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 }); + +const org = () => createOrganization({ slug: slug("acme"), name: name("Acme SA") }).getOrThrow(); + +const invoice = (total = money(12_00, "EUR")) => + createInvoice({ + issuedTo: org(), + lines: [], + total, + status: "DRAFT", + dunningReasons: [], + level: 0, + }).getOrThrow(); + +test("a factory supplies the generated fields", () => { + const acme = org(); + expect(acme.slug).toBe("acme"); + expect(acme.id).toMatch(/^[0-9a-f-]{36}$/); +}); + +test("a computed field is derived, and re-derived on update", () => { + const acme = org(); + expect(acme.displayLabel).toBe("Acme SA (acme)"); + + const renamed = acme.update({ name: name("Acme SAS") }).getOrThrow(); + expect(renamed.displayLabel).toBe("Acme SAS (acme)"); +}); + +test("an invariant returns an error rather than throwing", () => { + expect(createOrganization({ slug: slug("acme"), name: name("x".repeat(81)) }).isErr()).toBe(true); +}); + +test("toJSON is the stored shape, and never carries _tag", () => { + const stored = org().toJSON(); + + expect(Object.keys(stored).sort()).toEqual(["createdAt", "displayLabel", "id", "name", "slug"]); + expect("_tag" in stored).toBe(false); +}); + +test("update returns a new entity and leaves the original alone", () => { + const acme = org(); + const renamed = acme.update({ name: name("Acme SAS") }).getOrThrow(); + + expect(acme.name).toBe("Acme SA"); + expect(renamed.name).toBe("Acme SAS"); + expect(acme.equals(renamed)).toBe(false); +}); + +test("an entity nests inside another and survives the round trip", () => { + const drafted = invoice(); + expect(drafted.issuedTo).toBeInstanceOf(Organization); + + const rehydrated = Invoice.make(drafted.toJSON()).getOrThrow(); + expect(rehydrated.equals(drafted)).toBe(true); + expect(rehydrated.issuedTo).toBeInstanceOf(Organization); +}); + +test("a branded object field keeps its members", () => { + const drafted = invoice(money(999, "USD")); + + expect(drafted.total.amount).toBe(999); + expect(drafted.total.currency).toBe("USD"); +}); + +test("a malformed row comes back as an error, not an exception", () => { + expect(Organization.make({ slug: "", name: "" }).isErr()).toBe(true); +}); diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts new file mode 100644 index 0000000..aabefab --- /dev/null +++ b/examples/billing-domain/src/index.ts @@ -0,0 +1,169 @@ +/** + * A small billing domain, modelled with `@btravstack/entity`. + * + * Read it top to bottom: the field vocabulary first, then the two entities, + * then the factories binding them to their effect sources. Every shape here is + * one a billing model actually needs — including the two that once broke + * declaration emit for consumers: a branded `Money` object, and a dunning + * vocabulary wide enough to matter. See `emit-guards.ts`. + */ +import { Entity } from "@btravstack/entity"; +import { z } from "zod"; + +/* ── The field vocabulary ────────────────────────────────────────────── + Every data field is branded. A bare `z.string()` is a compile error, and + that is the point: an OrganizationId and a Slug are both strings, and the + model should not let you pass one where the other belongs. */ + +export const OrganizationId = z.uuid().brand("OrganizationId"); +export const InvoiceId = z.uuid().brand("InvoiceId"); +export const Slug = z.string().min(1).max(40).brand("Slug"); +export const DisplayName = z.string().min(1).brand("DisplayName"); +export const DisplayLabel = z.string().min(1).brand("DisplayLabel"); +export const Instant = z.iso.datetime().brand("Instant"); +export const LineLabel = z.string().min(1).brand("LineLabel"); + +export const Currency = z.enum(["EUR", "USD", "GBP"]); + +/** + * A value object: no identity, so it is a *branded object* rather than an + * entity. Amounts are integer minor units — `12_00` is €12.00 — because binary + * floats are the wrong tool for money. + */ +export const Money = z.object({ amount: z.number().int(), currency: Currency }).brand("Money"); + +export const LineItem = z + .object({ label: LineLabel, unit: Money, quantity: z.number().int().positive() }) + .brand("LineItem"); + +export const InvoiceStatus = z.enum(["DRAFT", "ISSUED", "PAID", "VOID", "UNCOLLECTIBLE"]); + +/** Escalation step of a dunning run. */ +export const Level = z.union([ + z.literal(0), + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), +]); + +/** + * Why an invoice entered dunning. Vocabularies this wide are ordinary in + * billing — and this one is kept at full width deliberately, because it is what + * pins issue #31. Read the note in `emit-guards.ts` before trimming it. + */ +export const DunningReason = z.enum([ + "CANCELED_LEASE", + "TENANT_LEAVE_BALANCE_DONE", + "SUBROGATIVE_RECEIPT_TO_BE_SIGNED", + "SUBROGATIVE_RECEIPT_SIGNED", + "NO_RGI_CLAIM", + "VISALE", + "MONTHLY_PAYMENT", + "GROWTH", + "UNIT_SOLD", + "EXPENSE_TRANSFER", + "DECEASED_TENANT", + "DECEASED_COOWNER", + "DISPUTE_CHARGES", + "DISPUTE_REPAIRS", + "OWNER_INSTRUCTIONS_EXCLUDING_GLI", + "CHECK_OR_CASH_NOT_RECORDED", + "AWAITING_CAF_PAYMENT", + "NEW_BUILDING", + "NEW_COOWNER", + "MANAGEMENT_DIFFICULTIES", + "SALE_IN_PROGRESS", + "PROMISE_OF_PAYMENT", + "FALSE_DISTRIBUTIONS", + "INSTITUTIONAL_COOWNER", + "HISTORICAL", + "TENANT_LEAVE_NO_REMINDER", + "EXTERNAL_RGI_DISASTER", + "OVER_INDEBTEDNESS_LEGAL_PROCEEDINGS", + "MEMORANDUM_OF_AGREEMENT", + "MANUAL_EXPENSE_TRANSFER", +]); + +/* ── The entities ──────────────────────────────────────────────────────── */ + +/** + * `generated` names the fields the domain produces rather than the caller, so + * they drop out of `createInput`. `immutable` names the ones `update` refuses. + * `computed` is re-derived on every construction path, so it cannot drift from + * its sources. + */ +export class Organization extends Entity("Organization")( + { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, + { + generated: ["id", "createdAt"], + immutable: ["id", "createdAt", "slug"], + computed: { + displayLabel: Entity.computed( + DisplayLabel, + (d) => `${d.name} (${d.slug})` as z.infer, + ), + }, + invariants: [ + Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"), + ], + }, +) { + /** Behaviour goes in the class body — this is a real class. */ + get isSelfTitled(): boolean { + return this.name.toLowerCase().startsWith(this.slug.toLowerCase()); + } +} + +/** + * `issuedTo` is another entity used directly as a field: the class is itself a + * zod schema, so it parses back to a real `Organization`, behaviour and all. + */ +export class Invoice extends Entity("Invoice")( + { + id: InvoiceId, + issuedTo: Organization, + lines: z.array(LineItem), + total: Money, + status: InvoiceStatus, + dunningReasons: z.array(DunningReason), + level: Level, + issuedAt: Instant, + }, + { + generated: ["id", "issuedAt"], + immutable: ["id", "issuedAt", "issuedTo"], + invariants: [ + Entity.invariant((d) => d.total.amount >= 0, "total must not be negative"), + Entity.invariant( + (d) => d.status !== "VOID" || d.dunningReasons.length === 0, + "a void invoice cannot be in dunning", + ), + ], + }, +) { + get isCollectable(): boolean { + return this.status === "ISSUED" || this.status === "DRAFT"; + } +} + +/** Dispatches on the declared discriminant, so a failing member reports its own issues. */ +export const BillingRecord = Entity.union("_tag", [Organization, Invoice] as const); + +/* ── Binding the effect sources ──────────────────────────────────────── + The package reads no clock and generates no id. A factory is where those + 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; + +export const createOrganization = Organization.factory({ + id: () => crypto.randomUUID() as z.infer, + createdAt: now, +}); + +export const createInvoice = Invoice.factory({ + id: () => crypto.randomUUID() as z.infer, + issuedAt: now, +}); diff --git a/examples/billing-domain/tsconfig.emit.json b/examples/billing-domain/tsconfig.emit.json new file mode 100644 index 0000000..957b2b5 --- /dev/null +++ b/examples/billing-domain/tsconfig.emit.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "outDir": "./node_modules/.emit-check" + } +} diff --git a/examples/billing-domain/tsconfig.json b/examples/billing-domain/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/billing-domain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/billing-domain/vitest.config.ts b/examples/billing-domain/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/billing-domain/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 735c2a6..2342f00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -160,6 +160,40 @@ importers: specifier: 'catalog:' version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.25)(typescript@6.0.3)(yaml@2.9.0) + examples/billing-domain: + dependencies: + '@btravstack/entity': + specifier: workspace:* + version: link:../../packages/entity + unthrown: + specifier: 'catalog:' + version: 5.1.0 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/standard-schema': + specifier: 'catalog:' + version: 5.1.0 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.1.0(unthrown@5.1.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + typescript-consumer: + specifier: 'catalog:' + version: typescript@5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + packages/entity: devDependencies: '@btravstack/tsconfig': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e20794d..9e2657e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ strictPeerDependencies: true packages: - packages/* + - examples/* - docs catalog: From adb019f444a43359cc923a96b545c6ba1defd898 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 23:24:56 +0200 Subject: [PATCH 2/6] refactor(examples): move the declaration-emit fixture out of packages/entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consumer/ proved a downstream library emitting its own declarations could build against this package, but it documented nothing and looked like nothing else in the stack. examples/billing-domain is that same proof as a readable example, and it resolves @btravstack/entity through the real `exports` rather than a paths mapping — closer to what a consumer does. Verified by breaking the library on purpose: with EntityStatic un-exported, billing-domain fails with TS4020 and TS7056, exactly as the old fixture did. Restored afterwards. Corrects a claim made when the second pass was added. It is NOT that the 7.x native port ignores TS7056 — both versions enforce it, and 5.9.3's threshold is simply lower. Measured on the old fixture's narrower entity: 5.9.3 reported TS7056, 7.0.2 accepted the same shape and reported only TS4020; widen the entity and both report it. The second pass is still worth its cost, for the sharper reason that a band of realistic domain widths fails for consumers and passes here. knip needed a config for the first time: introducing one replaces the defaults, so the *.test-d.ts exclusion and four config-file-only dependencies had to be spelled out. Each is verified referenced, and the reason is inline. --- CLAUDE.md | 33 ++-- examples/README.md | 14 +- examples/billing-domain/src/emit-guards.ts | 78 ++++++++++ knip.json | 19 --- knip.jsonc | 45 ++++++ packages/entity/consumer/index.ts | 168 --------------------- packages/entity/package.json | 3 +- packages/entity/tsconfig.consumer.json | 14 -- packages/entity/tsconfig.consumer5.json | 6 - pnpm-lock.yaml | 3 - 10 files changed, 156 insertions(+), 227 deletions(-) create mode 100644 examples/billing-domain/src/emit-guards.ts delete mode 100644 knip.json create mode 100644 knip.jsonc delete mode 100644 packages/entity/consumer/index.ts delete mode 100644 packages/entity/tsconfig.consumer.json delete mode 100644 packages/entity/tsconfig.consumer5.json diff --git a/CLAUDE.md b/CLAUDE.md index 6a48737..1ae1145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co fallible operation returns an `unthrown` `Result` instead of throwing. -pnpm + turbo monorepo with two workspaces: the package, `packages/entity`, and -the documentation site, `docs`. Root scripts delegate to turbo; workspace -scripts are where the real commands live. +pnpm + turbo monorepo with five workspaces: the package, `packages/entity`; the +documentation site, `docs`; and three example packages under `examples/`, which +document the library and double as its declaration-emit fixtures. Root scripts +delegate to turbo; workspace scripts are where the real commands live. ## Commands @@ -20,14 +21,24 @@ Scripts are in `package.json`; the root ones delegate to turbo. Three things that are not derivable from there: - **The gate CI runs, in order**: `format --check`, `lint`, `typecheck`, - `test`, `knip`, `build`. `typecheck` is four passes — the main `tsc`, the - `.test-d.ts` pass, and the consumer declaration-emit pass run **twice**: once - on the repo's TypeScript (7.0.2, the native port) and once on 5.9.3 through - the `typescript-consumer` alias. The second is not redundant. The native port - does not enforce the 5.x ceiling on serialised type length, so `TS7056` is - invisible to it — that gap shipped two declaration-emit bugs to a consumer - (#31, #32) while this repo's own gate stayed green. Consumers build with 5.x; - the gate has to as well. + `test`, `knip`, `build`. `typecheck` spans two workspaces: + `packages/entity` runs the main `tsc` plus the `.test-d.ts` pass, and + `examples/billing-domain` compiles **its own declarations twice** — once on + the repo's TypeScript (7.0.2) and once on 5.9.3 through the + `typescript-consumer` alias. That example is the fixture proving a downstream + library can build against this package; it is not decoration. + The second compiler is not redundant, though the reason is narrower than it + looks. **Both versions enforce `TS7056`; 5.9.3's threshold is simply lower.** + Measured on one entity carrying a 30-member enum, a branded timestamp and a + six-member literal union: 5.9.3 reported `TS7056`, 7.0.2 accepted the same + shape and reported only `TS4020`. Widen the entity and both report it. So a + band of realistic domain widths fails for consumers and passes here — + which is the band issues #31 and #32 shipped through. +- **A `paths` mapping is not how the emit fixture resolves the package.** + `examples/billing-domain` depends on `@btravstack/entity` as `workspace:*` + and reaches `dist/index.d.mts` through its real `exports`, the way an actual + consumer does. The deleted `packages/entity/consumer/` faked that with + `paths`. - **A single test file runs from inside `packages/entity`**, not the root. - **The docs site runs from inside `docs`**: `pnpm --filter ./docs dev`. `pnpm build` at the root builds it too, since it is a workspace. diff --git a/examples/README.md b/examples/README.md index 5d3dcc1..a40a9fa 100644 --- a/examples/README.md +++ b/examples/README.md @@ -27,10 +27,16 @@ can build against this package** — the case where a consumer sets `declaration: true` and TypeScript has to write entity's types into its own `.d.ts`. -That pass runs twice, on two TypeScript versions, and the second one is the -point: the repo builds with the 7.x native port, which does not enforce the 5.x -ceiling on serialised type length. Two declaration-emit bugs shipped through -that blind spot ([#31], [#32]) while every other check stayed green. +That pass runs twice, on TypeScript 7.0.2 and 5.9.3, and the second one earns +its place: **both enforce the ceiling on serialised type length, but 5.9.3's is +lower.** Measured on one entity carrying a 30-member enum, a branded timestamp +and a six-member literal union — 5.9.3 rejected it with `TS7056` while 7.0.2 +accepted the same shape. Widen the entity and both reject it. + +So there is a band of perfectly realistic domain widths that fails for a +consumer on 5.x and passes on the version this repo builds with. Two +declaration-emit bugs shipped through that band ([#31], [#32]) while every other +check stayed green. `billing-domain/src/emit-guards.ts` carries the assertions that have no runtime moment — the construction seal, a forged construction key, every namespace diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts new file mode 100644 index 0000000..8961894 --- /dev/null +++ b/examples/billing-domain/src/emit-guards.ts @@ -0,0 +1,78 @@ +/** + * NOT example code. Do not copy anything out of this file. + * + * It is a compile-time test that happens to live beside an example, because + * what it tests *is* what an example is: a downstream package that uses the + * library **and emits its own declarations**. It replaced + * `packages/entity/consumer/`. + * + * Two rules that are easy to destroy by tidying: + * + * 1. **An unused `@ts-expect-error` here is a failure, not noise.** A + * namespace member emitted as a circular self-alias still *compiles*; the + * type simply degenerates, and a directive going unused is the only + * signal. Every member of `Entity` is therefore named below, so + * declaration emit has to walk each one. + * + * 2. **The widths in `index.ts` are load bearing.** `TS7056` is a threshold on + * serialised *characters*, so `Invoice` needs its full dunning vocabulary, + * its branded timestamp and its six-member level union to stay above it. + * Measured: trimming them put the old fixture back under the ceiling, + * where it compiled happily and guarded nothing. + * + * What went wrong when nothing checked this: `EntityStatic` was unexported, so + * TypeScript had no name to write for the builder's return type and serialised + * the entire static surface into every consumer's `.d.ts` — a one-field entity + * emitted 274,048 bytes, against 240 now. A wide enum then crossed the ceiling + * (`TS7056`, #31) and a branded object reached zod's module-private `$brand` + * through `DeepReadonly` (`TS4020`, #32). `Entity.union` had the same problem + * one type further along (`TS4023`), found while writing this example. + */ +import { Entity } from "@btravstack/entity"; +import type { z } from "zod"; + +// `Organization` is imported as a value: the sealed-construction assertion +// below needs the runtime binding to write `new Organization(...)` at all. +import { Organization } from "./index.js"; +import type { DisplayLabel, Invoice, Slug } from "./index.js"; + +/* ── Construction stays sealed from outside the package ───────────────── */ + +// @ts-expect-error `new` is a compile error: every instance comes through make, update or a factory +void new Organization({ id: "x" as never, slug: "y" as never }); + +// @ts-expect-error the construction key cannot be forged structurally +const forged: Entity.ConstructionKey = {} as { seal: never }; +void forged; + +/* ── A branded object stays deep-readonly (issue #32) ─────────────────── */ + +export const readTotal = (invoice: Invoice): number => invoice.total.amount; + +export const mutateTotal = (invoice: Invoice): void => { + // @ts-expect-error a branded object's members are readonly all the way down + invoice.total.amount = 1; +}; + +/* ── Every namespace member, named so declaration emit walks it ───────── */ + +export type Row = Entity.Output; +export type Wire = Entity.Input; +export type NewOrg = Entity.CreateInput; +export type OrgPatch = Entity.Patch; +export type Derived = Entity.ComputedField }>; +export type Rule = Entity.Invariant<{ slug: z.infer }>; +export type SealedRow = Entity.Sealed; +export type Base = Entity.BaseInstance<{ slug: typeof Slug }, Record, []>; +export type Static = Entity.Static< + "Organization", + { slug: typeof Slug }, + Record, + [], + [] +>; +export type Members = Entity.Union<"_tag", [typeof Organization, typeof Invoice]>; + +/** The error is reachable as both a value and a type. */ +export const isInvalid = (error: unknown): error is Entity.InvalidEntity => + error instanceof Entity.InvalidEntity; diff --git a/knip.json b/knip.json deleted file mode 100644 index 12eac17..0000000 --- a/knip.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "https://unpkg.com/knip@6/schema.json", - "ignoreExportsUsedInFile": true, - "ignore": ["**/*.test-d.ts"], - "workspaces": { - "packages/*": { - "project": ["src/**/*.ts"] - }, - "docs": { - "project": [".vitepress/**/*.ts"] - } - }, - "ignoreDependencies": [ - "@btravstack/oxlint", - "@btravstack/lefthook", - "@btravstack/typedoc", - "typedoc-plugin-markdown" - ] -} diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 0000000..b678bd3 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,45 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + + // `ComputedOf`, `CreateInputOf` and `GeneratedOf` are exported from + // `types.ts` and consumed inside it — by `OutputOf`, `EntityStatic` and + // `Generators` respectively — plus `types.test-d.ts`, which this repo + // deliberately keeps out of knip's scope. Deleting + // `packages/entity/consumer/` removed the last cross-file reference and knip + // began reporting all three as dead. They are not: this setting says + // "used inside its own file still counts", which is the truth here and what + // the sibling repos configure. + "ignoreExportsUsedInFile": true, + + // Type-level behaviour lives in `*.test-d.ts`, checked by its own tsc pass and + // deliberately kept out of the main one, out of oxlint (`.oxlintrc.json`'s + // `ignorePatterns`) and out of knip. Nothing imports them — that is the design + // — so knip reads them as dead files. Spelled out here because introducing + // this config file at all replaced the defaults that had been excluding them. + "ignore": ["**/*.test-d.ts"], + + // Four config packages knip cannot trace, each verified referenced: + // `@btravstack/oxlint` from `.oxlintrc.json`'s `extends`, `@btravstack/lefthook` + // from `lefthook.yml`'s `extends`, and `@btravstack/typedoc` plus + // `typedoc-plugin-markdown` from `docs/typedoc.json`. Knip resolves none of + // these three config formats, so it reads all four as unused devDependencies. + "ignoreDependencies": [ + "@btravstack/lefthook", + "@btravstack/oxlint", + "@btravstack/typedoc", + "typedoc-plugin-markdown", + ], + + "workspaces": { + "examples/billing-domain": { + // `emit-guards.ts` is deliberately imported by nothing. It exists to be + // COMPILED: it is the declaration-emit fixture that replaced + // `packages/entity/consumer/`, and its assertions are `@ts-expect-error` + // directives with no runtime moment. Naming it an entry is what stops + // knip reporting the guard as dead code and someone helpfully deleting + // it. `src/index.ts` is not listed: it is already the package entry. + "entry": ["src/emit-guards.ts"], + "project": ["src/**/*.ts"], + }, + }, +} diff --git a/packages/entity/consumer/index.ts b/packages/entity/consumer/index.ts deleted file mode 100644 index 6e183f5..0000000 --- a/packages/entity/consumer/index.ts +++ /dev/null @@ -1,168 +0,0 @@ -/** - * A stand-in for a downstream *library*: it exports an entity subclass **and** - * emits its own declarations. That combination is what regressed before — a - * module-private `unique symbol` in the seal made every such consumer fail - * with TS4020, while this repo's own build (which does not emit declarations - * through `tsc`) stayed green. - * - * `tsconfig.consumer.json` compiles this, and both of its overrides are load - * bearing: it turns `noEmit` off because declaration emit is what surfaces a - * leaked private name, and points `paths` at `dist/*.d.mts` so this exercises - * the published types rather than `src`. - * - * It is also what guards the namespace: every member of `Entity` is reached - * through the built `d.mts` below, because a member emitted as a circular - * self-alias still *compiles* and only shows up as a `@ts-expect-error` here - * going unused. See the `Src` comment in `entity.ts`. An unused directive in - * this file is a failure, not noise. - */ -import { Entity } from "@btravstack/entity"; -import { z } from "zod"; - -const OrgId = z.uuid().brand("OrgId"); -const Slug = z.string().min(1).brand("Slug"); -const Upper = z.string().min(1).brand("Upper"); - -export class Organization extends Entity("Organization")( - { id: OrgId, slug: Slug }, - { - immutable: ["id"], - computed: { - shout: Entity.computed(Upper, (d) => d.slug.toUpperCase() as z.infer), - }, - invariants: [Entity.invariant((d) => d.slug.length <= 40, "slug must be at most 40 chars")], - }, -) {} - -/** The statics must still yield the subclass, not the structural base. */ -export const load = (raw: unknown): Organization => Organization.make(raw).getOrThrow(); - -/** The class must still compose as a schema from outside the package. */ -export const Aggregate = z.object({ owner: Organization }); - -// @ts-expect-error construction stays sealed for a consumer -new Organization({ id: "x" as never, slug: "y" as never }); - -// @ts-expect-error the construction key cannot be forged structurally -const forged: Entity.ConstructionKey = {} as { seal: never }; -void forged; - -/** - * Every remaining namespace member, named from outside the package. A member - * emitted as a circular self-alias degenerates silently, so each one has to be - * *used* somewhere declaration emit will walk it. - */ -export type Row = Entity.Output; -export type Wire = Entity.Input; -export type NewOrg = Entity.CreateInput; -export type OrgPatch = Entity.Patch; -export type Derived = Entity.ComputedField }>; -export type Rule = Entity.Invariant<{ slug: z.infer }>; -export type Sealed = Entity.Sealed; -export type Base = Entity.BaseInstance<{ id: typeof OrgId }, Record, []>; -export type Static = Entity.Static< - "Organization", - { id: typeof OrgId }, - Record, - [], - [] ->; - -/** The error is reachable as both a value and a type. */ -export const isInvalid = (e: unknown): e is Entity.InvalidEntity => - e instanceof Entity.InvalidEntity; - -const Member = Entity.union("kind", [Organization, Organization] as const); -export type MemberUnion = Entity.Union<"kind", [typeof Organization, typeof Organization]>; -void Member; - -/** - * The two field shapes that broke declaration emit while `EntityStatic` was - * unexported, both reported from a real adoption. - * - * With no name to write for the builder's return type, TypeScript serialised - * the whole static surface into this file's declarations, repeating the field - * map a dozen times. A **branded object** field was expanded through - * `DeepReadonly` until zod's module-private `$brand` symbol reached - * computed-key position, which cannot be named across a module boundary - * (`TS4020`, issue #32); and a realistically wide domain enum pushed the - * repeated field map past the compiler's serialisation ceiling (`TS7056`, - * issue #31). Both now emit as `EntityStatic<…>` by reference. - * - * Their guard value differs, and only one of them is carried by *this* pass: - * `TS4020` reproduces on the repo's TypeScript, so the branded object below - * fails here the moment the export is removed. `TS7056` is a 5.x-era limit the - * native port does not enforce, so the wide enum is checked by - * `tsconfig.consumer5.json` instead — see the comment there. - */ -const Money = z.object({ amount: z.number(), currency: z.enum(["EUR", "USD"]) }).brand("Money"); - -export class Invoice extends Entity("Invoice")({ id: OrgId, total: Money }) {} - -/** The branded object must stay *usable*, not merely compile. */ -export const invoiceTotal = (i: Invoice): number => i.total.amount; -export const invoiceCurrency = (i: Invoice): "EUR" | "USD" => i.total.currency; - -// @ts-expect-error a branded object's members stay deep-readonly -export const mutateTotal = (i: Invoice): void => void (i.total.amount = 1); - -const Reason = z.enum([ - "CANCELED_LEASE", - "TENANT_LEAVE_BALANCE_DONE", - "SUBROGATIVE_RECEIPT_TO_BE_SIGNED", - "SUBROGATIVE_RECEIPT_SIGNED", - "NO_RGI_CLAIM", - "VISALE", - "MONTHLY_PAYMENT", - "GROWTH", - "UNIT_SOLD", - "EXPENSE_TRANSFER", - "DECEASED_TENANT", - "DECEASED_COOWNER", - "DISPUTE_CHARGES", - "DISPUTE_REPAIRS", - "OWNER_INSTRUCTIONS_EXCLUDING_GLI", - "CHECK_OR_CASH_NOT_RECORDED", - "AWAITING_CAF_PAYMENT", - "NEW_BUILDING", - "NEW_COOWNER", - "MANAGEMENT_DIFFICULTIES", - "SALE_IN_PROGRESS", - "PROMISE_OF_PAYMENT", - "FALSE_DISTRIBUTIONS", - "INSTITUTIONAL_COOWNER", - "HISTORICAL", - "TENANT_LEAVE_NO_REMINDER", - "EXTERNAL_RGI_DISASTER", - "OVER_INDEBTEDNESS_LEGAL_PROCEEDINGS", - "MEMORANDUM_OF_AGREEMENT", - "MANUAL_EXPENSE_TRANSFER", -]); -const Level = z.union([ - z.literal(0), - z.literal(1), - z.literal(2), - z.literal(3), - z.literal(4), - z.literal(5), -]); -const Instant = z.date().brand("Instant"); - -/** - * Kept at the reported width on purpose. `TS7056` is a threshold on serialised - * *characters*, so a field map trimmed even slightly — the timestamp dropped, - * `Level` down to four members — lands back under the ceiling and the case - * silently stops guarding anything. Measured while writing this fixture. If - * this entity ever needs editing, re-check it still fails with the export - * removed. - */ -export class Reminder extends Entity("Reminder")({ - id: OrgId, - reasons: z.array(Reason), - createdAt: Instant, - status: z.enum(["ONGOING_REMINDER", "CLOSE"]), - kind: z.enum(["TENANT_IN_PLACE", "TENANT_LEAVE", "CO_OWNER"]), - level: Level, - nextLevel: Level, - flag: z.boolean(), -}) {} diff --git a/packages/entity/package.json b/packages/entity/package.json index b8db275..c23e518 100644 --- a/packages/entity/package.json +++ b/packages/entity/package.json @@ -49,7 +49,7 @@ "dev": "tsdown src/index.ts --format cjs,esm --dts --watch", "test": "vitest run", "test:types": "tsc --noEmit -p tsconfig.test-d.json", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && tsc -p tsconfig.consumer.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.consumer5.json" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" }, "devDependencies": { "@btravstack/tsconfig": "catalog:", @@ -60,7 +60,6 @@ "@vitest/coverage-v8": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", - "typescript-consumer": "catalog:", "unthrown": "catalog:", "vitest": "catalog:", "zod": "catalog:" diff --git a/packages/entity/tsconfig.consumer.json b/packages/entity/tsconfig.consumer.json deleted file mode 100644 index 1f9d266..0000000 --- a/packages/entity/tsconfig.consumer.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "@btravstack/tsconfig/base.json", - "compilerOptions": { - "noEmit": false, - "declaration": true, - "declarationMap": false, - "emitDeclarationOnly": true, - "outDir": "./node_modules/.consumer-check", - "rootDir": "./consumer", - "types": ["node"], - "paths": { "@btravstack/entity": ["./dist/index.d.mts"] } - }, - "include": ["consumer/**/*"] -} diff --git a/packages/entity/tsconfig.consumer5.json b/packages/entity/tsconfig.consumer5.json deleted file mode 100644 index 2d1ccf0..0000000 --- a/packages/entity/tsconfig.consumer5.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": "./tsconfig.consumer.json", - "compilerOptions": { - "outDir": "./node_modules/.consumer5-check" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2342f00..28f282f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,9 +220,6 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 - typescript-consumer: - specifier: 'catalog:' - version: typescript@5.9.3 unthrown: specifier: 'catalog:' version: 5.1.0 From 78293aab2aad93b34050f74f140a175ebad351f2 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 23:31:51 +0200 Subject: [PATCH 3/6] feat(examples): add billing-api, composing the four ZodObjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An oRPC contract and JSON Schema built from Organization.createInput / .updateInput / .output, with nothing restating the shape of an Organization. The spec pins the design rule both ways: the four plain ZodObjects convert in both directions, and handing the class to a converter throws, because it parses to an instance. The JSON Schema exports carry an explicit JsonSchema annotation. Without it TypeScript infers a type it cannot name from outside the package and consumers emitting declarations fail with TS2883 — the same class of problem as #31 and #32, met from the other side. --- examples/billing-api/README.md | 50 +++++++++++++++++++++ examples/billing-api/package.json | 30 +++++++++++++ examples/billing-api/src/index.spec.ts | 46 +++++++++++++++++++ examples/billing-api/src/index.ts | 62 ++++++++++++++++++++++++++ examples/billing-api/tsconfig.json | 8 ++++ examples/billing-api/vitest.config.ts | 9 ++++ pnpm-lock.yaml | 40 +++++++++++++++++ pnpm-workspace.yaml | 18 +++++--- 8 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 examples/billing-api/README.md create mode 100644 examples/billing-api/package.json create mode 100644 examples/billing-api/src/index.spec.ts create mode 100644 examples/billing-api/src/index.ts create mode 100644 examples/billing-api/tsconfig.json create mode 100644 examples/billing-api/vitest.config.ts diff --git a/examples/billing-api/README.md b/examples/billing-api/README.md new file mode 100644 index 0000000..b712b28 --- /dev/null +++ b/examples/billing-api/README.md @@ -0,0 +1,50 @@ +# billing-api + +The contract half: turning an entity into request and response schemas for +routes, without hand-writing omit lists that drift from the model. + +```sh +pnpm --filter @btravstack/entity-example-billing-api test +``` + +## The rule + +> **Contracts compose the four plain `ZodObject`s; domain code composes the +> class.** + +`Organization.createInput`, `.updateInput`, `.input` and `.output` are ordinary +`ZodObject`s derived from one field map, so: + +- they convert to JSON Schema in **both** directions, +- the usual combinators work on them (`.pick`, nesting in `z.object`, arrays), +- nothing restates the shape of an `Organization` anywhere in this package. + +`createInput` is the field map minus whatever the entity declares `generated`; +`updateInput` is it minus `immutable` and minus the computed fields, with every +remaining key optional. Add a generated field to the entity and the create body +follows on its own — that is the omit list you did not have to write. + +The class itself deliberately **does not** convert: + +```ts +z.toJSONSchema(Organization, { io: "output" }); // throws, by design +``` + +It carries a `.transform()` — it parses to an _instance_, not to plain data — +and a transforming schema has no output representation. That is the reason the +four plain `ZodObject`s exist separately, and the spec pins it both ways. + +See also the how-to: [Expose an HTTP +contract](https://btravstack.github.io/entity/how-to/http-contract). + +## One thing worth copying + +The JSON Schema exports carry an explicit `JsonSchema` annotation. That is not +style. Without it TypeScript infers a type it cannot _name_ from outside the +package, and any consumer emitting declarations fails with `TS2883` — "cannot +be named without a reference to 'JsonSchema' … this is likely not portable". +It is the same class of problem as [#31] and [#32], met from the other side, +and the cure is the same: give the type a name. + +[#31]: https://github.com/btravstack/entity/issues/31 +[#32]: https://github.com/btravstack/entity/issues/32 diff --git a/examples/billing-api/package.json b/examples/billing-api/package.json new file mode 100644 index 0000000..50b6425 --- /dev/null +++ b/examples/billing-api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@btravstack/entity-example-billing-api", + "private": true, + "description": "Composing an entity's four ZodObjects into an HTTP contract", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btravstack/entity-example-billing-domain": "workspace:*", + "@orpc/contract": "catalog:", + "@orpc/json-schema": "catalog:", + "@orpc/zod": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/billing-api/src/index.spec.ts b/examples/billing-api/src/index.spec.ts new file mode 100644 index 0000000..2a95507 --- /dev/null +++ b/examples/billing-api/src/index.spec.ts @@ -0,0 +1,46 @@ +import { Organization } from "@btravstack/entity-example-billing-domain"; +import { expect, test } from "vitest"; +import { z } from "zod"; + +import { + createOrganizationSchema, + organizationContract, + organizationResponseSchema, +} from "./index.js"; + +const propertiesOf = (schema: unknown) => + Object.keys((schema as { properties: Record }).properties).sort(); + +test("the create body drops whatever the domain generates", () => { + // `id` and `createdAt` are `generated`, so no caller ever supplies them — + // and nobody had to write an omit list saying so. + expect(propertiesOf(createOrganizationSchema)).toEqual(["name", "slug"]); +}); + +test("the response body is the stored shape, computed field included", () => { + expect(propertiesOf(organizationResponseSchema)).toEqual([ + "createdAt", + "displayLabel", + "id", + "name", + "slug", + ]); +}); + +test("the four ZodObjects convert in both directions", () => { + expect(() => z.toJSONSchema(Organization.createInput, { io: "input" })).not.toThrow(); + expect(() => z.toJSONSchema(Organization.updateInput, { io: "input" })).not.toThrow(); + expect(() => z.toJSONSchema(Organization.input, { io: "input" })).not.toThrow(); + expect(() => z.toJSONSchema(Organization.output, { io: "output" })).not.toThrow(); +}); + +test("the class itself does not convert — and that is the design", () => { + // It carries a .transform(): it parses to an *instance*, not to plain data, + // and a transforming schema has no output representation. That is exactly + // why the four plain ZodObjects exist separately. + expect(() => z.toJSONSchema(Organization, { io: "output" })).toThrow(); +}); + +test("the contract exposes one procedure per route", () => { + expect(Object.keys(organizationContract).sort()).toEqual(["create", "list", "update"]); +}); diff --git a/examples/billing-api/src/index.ts b/examples/billing-api/src/index.ts new file mode 100644 index 0000000..aef44fb --- /dev/null +++ b/examples/billing-api/src/index.ts @@ -0,0 +1,62 @@ +/** + * The rule this package exists to show: + * **contracts compose the four plain `ZodObject`s; domain code composes the class.** + * + * `Organization.createInput` / `.updateInput` / `.input` / `.output` are + * ordinary `ZodObject`s derived from one field map, so they convert to JSON + * Schema in both directions and need no hand-written omit lists. The class + * itself deliberately does not convert — it parses to an *instance*, and a + * transforming schema has no output representation. + */ +import { Organization } from "@btravstack/entity-example-billing-domain"; +import { oc } from "@orpc/contract"; +import type { JsonSchema } from "@orpc/json-schema"; +import { ZodToJsonSchemaConverter } from "@orpc/zod"; +import { z } from "zod"; + +/* ── The request and response shapes ─────────────────────────────────── + 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 entity and the create body follows on its own. */ + +export const CreateOrganizationBody = Organization.createInput; +export const UpdateOrganizationBody = Organization.updateInput; +export const OrganizationResponse = Organization.output; + +/** They are ordinary `ZodObject`s, so the usual combinators work. */ +export const OrganizationSummary = Organization.output.pick({ id: true, slug: true }); +export const OrganizationListing = z.object({ + items: z.array(Organization.output), + total: z.number().int(), +}); + +/* ── JSON Schema, in both directions ─────────────────────────────────── + `convert` returns `[jsonSchema, optional]`. The explicit `JsonSchema` + annotation is load bearing rather than decorative: without it TypeScript + infers a type it cannot *name* from outside this package, and any consumer + emitting declarations fails with `TS2883` — "cannot be named without a + reference to 'JsonSchema' … this is likely not portable". Exactly the class + of problem behind issues #31 and #32, met from the other side; the cure + here is simply to name the type. */ + +const converter = new ZodToJsonSchemaConverter(); + +const jsonSchemaOf = ( + schema: Parameters[0], + direction: "input" | "output", +): JsonSchema => converter.convert(schema, direction)[0]; + +export const createOrganizationSchema: JsonSchema = jsonSchemaOf(CreateOrganizationBody, "input"); +export const updateOrganizationSchema: JsonSchema = jsonSchemaOf(UpdateOrganizationBody, "input"); +export const organizationResponseSchema: JsonSchema = jsonSchemaOf(OrganizationResponse, "output"); + +/* ── The contract ────────────────────────────────────────────────────── + An oRPC procedure per route, each one taking the entity's own schemas. + Nothing here restates the shape of an Organization. */ + +export const organizationContract = { + create: oc.input(CreateOrganizationBody).output(OrganizationResponse), + update: oc.input(UpdateOrganizationBody).output(OrganizationResponse), + list: oc.output(OrganizationListing), +}; diff --git a/examples/billing-api/tsconfig.json b/examples/billing-api/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/billing-api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/billing-api/vitest.config.ts b/examples/billing-api/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/billing-api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28f282f..1b3eb11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,12 @@ catalogs: '@commitlint/cli': specifier: 21.2.1 version: 21.2.1 + '@orpc/contract': + specifier: 2.0.0-beta.24 + version: 2.0.0-beta.24 + '@orpc/json-schema': + specifier: 2.0.0-beta.24 + version: 2.0.0-beta.24 '@orpc/zod': specifier: 2.0.0-beta.24 version: 2.0.0-beta.24 @@ -160,6 +166,40 @@ importers: specifier: 'catalog:' version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.1.2)(jiti@2.7.0)(postcss@8.5.25)(typescript@6.0.3)(yaml@2.9.0) + examples/billing-api: + dependencies: + '@btravstack/entity-example-billing-domain': + specifier: workspace:* + version: link:../billing-domain + '@orpc/contract': + specifier: 'catalog:' + version: 2.0.0-beta.24 + '@orpc/json-schema': + specifier: 'catalog:' + version: 2.0.0-beta.24 + '@orpc/zod': + specifier: 'catalog:' + version: 2.0.0-beta.24(zod@4.4.3) + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.1.0(unthrown@5.1.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + examples/billing-domain: dependencies: '@btravstack/entity': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e2657e..f920d3a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,12 @@ catalog: # flag). `@orpc/zod`'s stable "latest" line (1.x) predates the v2 API this # package's contract tests exercise. "@orpc/zod": 2.0.0-beta.24 + # Same release train as `@orpc/zod` above — the billing-api example defines a + # contract and converts it to JSON Schema in the same file, so they move together. + "@orpc/contract": 2.0.0-beta.24 + # `ZodToJsonSchemaConverter.convert` returns `[JsonSchema, optional]`, and + # naming `JsonSchema` is what keeps an exported schema portable (TS2883). + "@orpc/json-schema": 2.0.0-beta.24 "@types/node": 26.1.2 "@unthrown/oxlint": 5.1.0 "@unthrown/standard-schema": 5.1.0 @@ -42,11 +48,13 @@ catalog: # `types.d.ts`). Kept in step with that measurement rather than trailing it. typescript: 7.0.2 # The TypeScript consumers actually build with, for the second declaration-emit - # pass (`tsconfig.consumer5.json`). The native port above does **not** enforce - # the 5.x serialised-type ceiling, so `TS7056` is invisible to it: issue #31 - # reproduced on 5.9.3 against a 30-member domain enum while the repo's own - # consumer pass stayed green on 7.0.2. Aliased because one `package.json` - # cannot name `typescript` twice. + # pass in `examples/billing-domain` (`tsconfig.emit.json`, run twice). + # Both versions enforce the ceiling on serialised type length; 5.9.3's is + # simply lower. Measured on one entity carrying a 30-member enum, a branded + # timestamp and a six-member literal union: 5.9.3 rejected it with `TS7056` + # while 7.0.2 accepted the same shape — widen the entity and both reject it. + # That band of realistic domain widths is where issues #31 and #32 shipped. + # Aliased because one `package.json` cannot name `typescript` twice. typescript-consumer: "npm:typescript@5.9.3" unthrown: 5.1.0 "@vitest/coverage-v8": 4.1.10 From 2069aee0b2b788a38f62659b4f2edc160cb7b083 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 23:33:24 +0200 Subject: [PATCH 4/6] feat(examples): add billing-persistence, the storage round trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toJSON() out, make() back, over an in-memory Map. The specs pin what is easy to lose: _tag never reaching a row, behaviour surviving rehydration (not just data), and update leaving the entity in hand untouched. byId returns InvalidEntity | OrganizationNotFound rather than folding the two together — a missing row is a 404, a corrupt row is worth paging someone about, and collapsing them discards the only fact that separates them. The library defines no NotFound on purpose: whether an absent row is exceptional belongs to the repository, so the example models it. No try/catch anywhere in the file. --- examples/billing-persistence/README.md | 49 ++++++++++++ examples/billing-persistence/package.json | 29 +++++++ .../billing-persistence/src/index.spec.ts | 80 +++++++++++++++++++ examples/billing-persistence/src/index.ts | 63 +++++++++++++++ examples/billing-persistence/tsconfig.json | 8 ++ examples/billing-persistence/vitest.config.ts | 9 +++ pnpm-lock.yaml | 31 +++++++ 7 files changed, 269 insertions(+) create mode 100644 examples/billing-persistence/README.md create mode 100644 examples/billing-persistence/package.json create mode 100644 examples/billing-persistence/src/index.spec.ts create mode 100644 examples/billing-persistence/src/index.ts create mode 100644 examples/billing-persistence/tsconfig.json create mode 100644 examples/billing-persistence/vitest.config.ts diff --git a/examples/billing-persistence/README.md b/examples/billing-persistence/README.md new file mode 100644 index 0000000..012f861 --- /dev/null +++ b/examples/billing-persistence/README.md @@ -0,0 +1,49 @@ +# billing-persistence + +The storage half: an entity out to a row, and a row back to an entity. + +```sh +pnpm --filter @btravstack/entity-example-billing-persistence test +``` + +## The round trip + +```ts +repository.save(organization); // organization.toJSON() → the row +repository.byId(id); // Organization.make(row) → Result +``` + +`toJSON()` is the only projection the package offers, and it **is** the stored +shape. There is no mapper to keep in sync, and `_tag` never appears in a row: +it is a non-enumerable instance property, so it survives neither +`JSON.stringify` nor a spread. The spec asserts that, because it is the sort of +thing that silently starts leaking. + +`make()` is the way back in, and it validates. Rows outlive models — a column +you dropped two migrations ago is still sitting in production — so the boundary +where old data becomes a live object is exactly where you want a check. + +## Two errors, not one + +`byId` returns `Result`. + +A missing row and a _corrupt_ row are different facts: the first is a 404, the +second is data worth paging someone about. Collapsing them into one error +throws away the only information the caller needs to tell them apart. + +The library deliberately defines no `NotFound` — whether an absent row is +exceptional is a repository's decision, not an entity's — so this package +models it with `unthrown`'s `TaggedError`. The specs discriminate the two with +an exhaustive matcher, which means the day this repository grows a third error, +those call sites stop compiling until someone decides what to do about it. + +Nothing here throws. There is no `try`/`catch` in the file. + +## Swapping the store + +The store is a `Map`. Replace it with a driver and nothing else in this file +changes shape — which is the point of the entity knowing nothing about +persistence. + +See also the how-to: [Persist and +rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate). diff --git a/examples/billing-persistence/package.json b/examples/billing-persistence/package.json new file mode 100644 index 0000000..d07de5a --- /dev/null +++ b/examples/billing-persistence/package.json @@ -0,0 +1,29 @@ +{ + "name": "@btravstack/entity-example-billing-persistence", + "private": true, + "description": "Storing and rehydrating entities", + "license": "MIT", + "author": "Benoit TRAVERS ", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@btravstack/entity": "workspace:*", + "@btravstack/entity-example-billing-domain": "workspace:*", + "unthrown": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@btravstack/tsconfig": "catalog:", + "@types/node": "catalog:", + "@unthrown/vitest": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/examples/billing-persistence/src/index.spec.ts b/examples/billing-persistence/src/index.spec.ts new file mode 100644 index 0000000..9c17b74 --- /dev/null +++ b/examples/billing-persistence/src/index.spec.ts @@ -0,0 +1,80 @@ +import { DisplayName, Slug, createOrganization } from "@btravstack/entity-example-billing-domain"; +import { P } from "unthrown"; +import { expect, test } from "vitest"; + +import { InMemoryOrganizationRepository } from "./index.js"; + +const acme = () => + createOrganization({ + slug: Slug.parse("acme"), + name: DisplayName.parse("Acme SA"), + }).getOrThrow(); + +test("an entity survives the round trip through storage", () => { + const repo = new InMemoryOrganizationRepository(); + const saved = acme(); + repo.save(saved); + + const loaded = repo.byId(saved.id).getOrThrow(); + expect(loaded.equals(saved)).toBe(true); + // Behaviour comes back too, not just data — `make` returns a real instance. + expect(loaded.isSelfTitled).toBe(true); +}); + +test("the stored row is exactly the shape, and never carries _tag", () => { + const repo = new InMemoryOrganizationRepository(); + const saved = acme(); + repo.save(saved); + + const row = repo.rawRow(saved.id); + expect(row).toBeDefined(); + expect("_tag" in row!).toBe(false); + expect(Object.keys(row!).sort()).toEqual(["createdAt", "displayLabel", "id", "name", "slug"]); +}); + +test("a corrupt row comes back as an error, not an exception", async () => { + const repo = new InMemoryOrganizationRepository(); + const id = "11111111-1111-4111-8111-111111111111"; + repo.seedRaw(id, { id, slug: "", name: "" }); + + const loaded = repo.byId(id as never); + expect(loaded.isErr()).toBe(true); + + const kind = await loaded.match({ + ok: () => "ok", + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => "invalid") + .with(P.tag("OrganizationNotFound"), () => "not-found"), + defect: () => "defect", + }); + expect(kind).toBe("invalid"); +}); + +test("a missing row is NotFound, which is a different fact from corrupt", async () => { + const repo = new InMemoryOrganizationRepository(); + const missing = repo.byId("22222222-2222-4222-8222-222222222222" as never); + + expect(missing.isErr()).toBe(true); + const kind = await missing.match({ + ok: () => "ok", + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => "invalid") + .with(P.tag("OrganizationNotFound"), () => "not-found"), + defect: () => "defect", + }); + expect(kind).toBe("not-found"); +}); + +test("update writes a new row and leaves the entity in hand untouched", () => { + const repo = new InMemoryOrganizationRepository(); + const saved = acme(); + repo.save(saved); + + const renamed = saved.update({ name: DisplayName.parse("Acme SAS") }).getOrThrow(); + repo.save(renamed); + + expect(repo.byId(saved.id).getOrThrow().name).toBe("Acme SAS"); + expect(saved.name).toBe("Acme SA"); +}); diff --git a/examples/billing-persistence/src/index.ts b/examples/billing-persistence/src/index.ts new file mode 100644 index 0000000..25b36ec --- /dev/null +++ b/examples/billing-persistence/src/index.ts @@ -0,0 +1,63 @@ +/** + * Storing and rehydrating entities. + * + * `toJSON()` is the only projection the package offers, and it is exactly the + * stored shape — `_tag` is a non-enumerable instance property, so it never + * reaches a row, a `JSON.stringify`, or a spread. `make()` is the way back in, + * and it validates, so a corrupt row is an `InvalidEntity` **value** rather + * than an exception: this repository has nothing to `try`/`catch`. + * + * The store here is a `Map`. Swap it for a driver and nothing else moves — + * that is the point of the entity knowing nothing about persistence. + */ +import type { Entity } from "@btravstack/entity"; +import { Organization } from "@btravstack/entity-example-billing-domain"; +import { Err, TaggedError, type Result } from "unthrown"; + +type OrganizationId = Organization["id"]; +type Row = ReturnType; + +/** + * A missing row is not a *malformed* row, and collapsing the two would throw + * away the distinction the caller needs: one is a 404, the other is corrupt + * data worth paging someone about. The library deliberately defines no + * `NotFound` — whether an absent row is exceptional is a repository's decision, + * so it is modelled here. + */ +export class OrganizationNotFound extends TaggedError("OrganizationNotFound") {} + +export type OrganizationRepository = { + save(organization: Organization): void; + byId(id: OrganizationId): Result; +}; + +export class InMemoryOrganizationRepository implements OrganizationRepository { + readonly #rows = new Map(); + + save(organization: Organization): void { + // Exactly the stored shape. No mapper, no omit list, no `_tag`. + this.#rows.set(organization.id, organization.toJSON()); + } + + byId(id: OrganizationId): Result { + const row = this.#rows.get(id); + + if (row === undefined) { + return Err(new OrganizationNotFound()); + } + + // `make` validates on the way in, so whatever the store handed back is + // checked before it becomes an entity — rows predate the current model. + return Organization.make(row); + } + + /** Test seam: what actually landed in the store. */ + rawRow(id: OrganizationId): Row | undefined { + return this.#rows.get(id) as Row | undefined; + } + + /** Test seam: a row that never came from a valid entity. */ + seedRaw(id: string, row: unknown): void { + this.#rows.set(id, row); + } +} diff --git a/examples/billing-persistence/tsconfig.json b/examples/billing-persistence/tsconfig.json new file mode 100644 index 0000000..7fa4349 --- /dev/null +++ b/examples/billing-persistence/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@btravstack/tsconfig/base.json", + "compilerOptions": { + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/examples/billing-persistence/vitest.config.ts b/examples/billing-persistence/vitest.config.ts new file mode 100644 index 0000000..fb76260 --- /dev/null +++ b/examples/billing-persistence/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.spec.ts"], + setupFiles: ["@unthrown/vitest"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b3eb11..8f40470 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,37 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + examples/billing-persistence: + dependencies: + '@btravstack/entity': + specifier: workspace:* + version: link:../../packages/entity + '@btravstack/entity-example-billing-domain': + specifier: workspace:* + version: link:../billing-domain + unthrown: + specifier: 'catalog:' + version: 5.1.0 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@btravstack/tsconfig': + specifier: 'catalog:' + version: 0.2.0 + '@types/node': + specifier: 'catalog:' + version: 26.1.2 + '@unthrown/vitest': + specifier: 'catalog:' + version: 5.1.0(unthrown@5.1.0)(vitest@4.1.10) + typescript: + specifier: 'catalog:' + version: 7.0.2 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) + packages/entity: devDependencies: '@btravstack/tsconfig': From c79ddeb8a65cea9bf66e683e602b9510c0eda698 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 23:35:51 +0200 Subject: [PATCH 5/6] docs: add the examples section An overview plus a page per example, in the nav and sharing the guide sidebar so a reader landing on one still reaches every other page. The overview says what amqp-contract's does and what is true here: unlike every fenced block in the rest of the guide, this code compiles and its specs run in CI. Those snippets are checked by review and nothing else, so they can drift from the library without any build noticing. --- docs/.vitepress/config.ts | 17 ++++ docs/examples/billing-api.md | 85 ++++++++++++++++++++ docs/examples/billing-domain.md | 116 +++++++++++++++++++++++++++ docs/examples/billing-persistence.md | 77 ++++++++++++++++++ docs/examples/index.md | 54 +++++++++++++ 5 files changed, 349 insertions(+) create mode 100644 docs/examples/billing-api.md create mode 100644 docs/examples/billing-domain.md create mode 100644 docs/examples/billing-persistence.md create mode 100644 docs/examples/index.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index f9912bc..75a4b8e 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -50,6 +50,18 @@ const GUIDE_SIDEBAR = [ }, ]; +// The runnable packages under `examples/`. Unlike every fenced block in the +// guide, that code compiles and its specs run in CI. +const EXAMPLES_SECTION = { + text: "Examples", + items: [ + { text: "Overview", link: "/examples/" }, + { text: "Billing domain", link: "/examples/billing-domain" }, + { text: "HTTP contract", link: "/examples/billing-api" }, + { text: "Persistence", link: "/examples/billing-persistence" }, + ], +}; + // https://vitepress.dev/reference/site-config export default defineConfig({ title: "entity", @@ -125,6 +137,7 @@ export default defineConfig({ { text: "Explanation", link: "/explanation/why-entity" }, ], }, + { text: "Examples", link: "/examples/" }, { text: "API", link: "/api/" }, { text: "Changelog", @@ -143,6 +156,10 @@ export default defineConfig({ GUIDE_SIDEBAR, ]), ), + // The examples carry the guide sidebar too, with their own section on + // top: they are walkthroughs of the same material, so a reader landing on + // one should still reach every page of the guide. + "/examples/": [EXAMPLES_SECTION, ...GUIDE_SIDEBAR], "/api/": [ { text: "API Reference", diff --git a/docs/examples/billing-api.md b/docs/examples/billing-api.md new file mode 100644 index 0000000..8986862 --- /dev/null +++ b/docs/examples/billing-api.md @@ -0,0 +1,85 @@ +--- +title: HTTP contract example +description: Composing an entity's four plain ZodObjects into an oRPC contract and JSON Schema, with no hand-written omit lists. +--- + +# HTTP contract + +[`examples/billing-api`](https://github.com/btravstack/entity/tree/main/examples/billing-api) +— turning an entity into request and response schemas for routes. + +```sh +pnpm --filter @btravstack/entity-example-billing-api test +``` + +## The rule the package turns on + +> **Contracts compose the four plain `ZodObject`s; domain code composes the +> class.** + +```ts +export const CreateOrganizationBody = Organization.createInput; +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 +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`. + +They are ordinary `ZodObject`s, so the usual combinators work: + +```ts +export const OrganizationSummary = Organization.output.pick({ + id: true, + slug: true, +}); +export const OrganizationListing = z.object({ + items: z.array(Organization.output), + total: z.number().int(), +}); +``` + +## Both directions + +```ts +const converter = new ZodToJsonSchemaConverter(); +converter.convert(Organization.createInput, "input"); +converter.convert(Organization.output, "output"); +``` + +Or through zod directly, with `z.toJSONSchema(…, { io: "input" | "output" })`. + +## And the class, deliberately, does not + +```ts +z.toJSONSchema(Organization, { io: "output" }); // throws, by design +``` + +The class carries a `.transform()` — it parses to an _instance_, not to plain +data — and a transforming schema has no output representation. That is the whole +reason the four plain `ZodObject`s exist separately, and the example's spec pins +it in both directions: the four convert, the class throws. + +## One detail worth copying + +The JSON Schema exports carry an explicit `JsonSchema` annotation: + +```ts +export const createOrganizationSchema: JsonSchema = jsonSchemaOf( + CreateOrganizationBody, + "input", +); +``` + +Without it TypeScript infers a type it cannot **name** from outside the package, +and any consumer emitting declarations fails with `TS2883` — _"cannot be named +without a reference to 'JsonSchema' … this is likely not portable"_. It is the +same class of problem as [#31](https://github.com/btravstack/entity/issues/31) +and [#32](https://github.com/btravstack/entity/issues/32), met from the other +side of the boundary, and the cure is the same: give the type a name. + +Related how-to: [Expose an HTTP contract](/how-to/http-contract). diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md new file mode 100644 index 0000000..dbcb218 --- /dev/null +++ b/docs/examples/billing-domain.md @@ -0,0 +1,116 @@ +--- +title: Billing domain example +description: Declaring entities — branded fields, generated/immutable/computed, invariants, nesting, unions and factories — in a runnable package. +--- + +# Billing domain + +[`examples/billing-domain`](https://github.com/btravstack/entity/tree/main/examples/billing-domain) +— the modelling half: two entities and the vocabulary they are built from. + +```sh +pnpm --filter @btravstack/entity-example-billing-domain test +``` + +## The vocabulary comes first + +```ts +export const OrganizationId = z.uuid().brand("OrganizationId"); +export const Slug = z.string().min(1).max(40).brand("Slug"); +export const Instant = z.iso.datetime().brand("Instant"); +``` + +Every data field is branded, and a bare `z.string()` is a **compile error**. +That is the guard, not an inconvenience: an `OrganizationId` and a `Slug` are +both strings at runtime, and nothing except a brand stops you passing one where +the other belongs. + +`Money` is branded too, but it is an _object_: + +```ts +export const Money = z + .object({ amount: z.number().int(), currency: Currency }) + .brand("Money"); +``` + +A value object — no identity, so it is branded rather than made an entity. +Amounts are integer minor units because binary floats are the wrong tool for +money. Minting one takes `Money.parse({ … })`; a plain object literal does not +satisfy the branded type, which is exactly the point. + +## The entity + +```ts +export class Organization extends Entity("Organization")( + { id: OrganizationId, slug: Slug, name: DisplayName, createdAt: Instant }, + { + generated: ["id", "createdAt"], + immutable: ["id", "createdAt", "slug"], + computed: { + displayLabel: Entity.computed( + DisplayLabel, + (d) => `${d.name} (${d.slug})` as z.infer, + ), + }, + invariants: [ + Entity.invariant( + (d) => d.name.length <= 80, + "name must be at most 80 characters", + ), + ], + }, +) { + get isSelfTitled(): boolean { + return this.name.toLowerCase().startsWith(this.slug.toLowerCase()); + } +} +``` + +`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. + +Behaviour lives in the class body. This is a real class, not a record with +functions bolted beside it. + +## Nesting, and the factory + +`Invoice.issuedTo` is an `Organization` used directly as a field. The class is +itself a zod schema, so it parses back to a real instance: + +```ts +const rehydrated = Invoice.make(invoice.toJSON()).getOrThrow(); +rehydrated.issuedTo instanceof Organization; // true +``` + +The package reads no clock and generates no id, so a factory is where those come +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, +}); +``` + +That is what leaves the entities trivially testable: nothing inside them reaches +for ambient state. + +## Two things in this package that look odd on purpose + +**`DunningReason` has thirty members.** Vocabularies that wide are ordinary in +billing, and this one is held at full width because it pins +[#31](https://github.com/btravstack/entity/issues/31). `TS7056` is a threshold +on serialised _characters_, so trimming the enum puts the example back under the +ceiling, where it compiles and guards nothing. + +**`src/emit-guards.ts` is not example code.** It carries the assertions that +have no runtime moment — construction staying sealed, a construction key that +cannot be forged structurally, every `Entity.*` namespace member named so +declaration emit walks it. An **unused** `@ts-expect-error` in that file is a +failure rather than noise, because a namespace member emitted as a circular +self-alias still compiles and simply degenerates. + +Related reference: [Declaring an entity](/reference/declaration). diff --git a/docs/examples/billing-persistence.md b/docs/examples/billing-persistence.md new file mode 100644 index 0000000..782acbe --- /dev/null +++ b/docs/examples/billing-persistence.md @@ -0,0 +1,77 @@ +--- +title: Persistence example +description: Storing and rehydrating entities — toJSON() out, make() back, with a corrupt row arriving as a Result rather than a throw. +--- + +# Persistence + +[`examples/billing-persistence`](https://github.com/btravstack/entity/tree/main/examples/billing-persistence) +— an entity out to a row, and a row back to an entity. + +```sh +pnpm --filter @btravstack/entity-example-billing-persistence test +``` + +## The round trip + +```ts +save(organization: Organization): void { + this.#rows.set(organization.id, organization.toJSON()); +} +``` + +`toJSON()` is the only projection the package offers, and it **is** the stored +shape. No mapper to keep in sync, and `_tag` never reaches a row — it is a +non-enumerable instance property, so it survives neither `JSON.stringify` nor a +spread. The spec asserts that explicitly, because it is the sort of thing that +starts leaking quietly. + +```ts +byId(id): Result { + const row = this.#rows.get(id); + if (row === undefined) return Err(new OrganizationNotFound()); + return Organization.make(row); +} +``` + +`make()` validates on the way in. Rows outlive models — a column dropped two +migrations ago is still sitting in production — so the boundary where old data +becomes a live object is exactly where a check belongs. + +What comes back is a real instance, behaviour included, not a bag of data. The +spec checks that by calling a getter on a rehydrated entity. + +## Two errors, not one + +A missing row and a **corrupt** row are different facts: the first is a 404, the +second is data worth paging someone about. Folding them into one error discards +the only thing that separates them. + +The library defines no `NotFound` on purpose — whether an absent row is +exceptional is a repository's decision, not an entity's — so the example models +it with `unthrown`'s `TaggedError` and discriminates the two with an exhaustive +matcher: + +```ts +loaded.match({ + ok: (organization) => organization, + errCases: (m) => + m + .with(P.tag("InvalidEntity"), () => 422) + .with(P.tag("OrganizationNotFound"), () => 404), + defect: () => 500, +}); +``` + +Because the matcher is exhaustive, the day this repository grows a third error +those call sites stop compiling until someone decides what to do about it. + +There is no `try`/`catch` anywhere in the file. + +## Swapping the store + +The store is a `Map`. Replace it with a driver and nothing else in the file +changes shape — which is the point of the entity knowing nothing about +persistence in the first place. + +Related how-to: [Persist and rehydrate](/how-to/persist-and-rehydrate). diff --git a/docs/examples/index.md b/docs/examples/index.md new file mode 100644 index 0000000..2b053d3 --- /dev/null +++ b/docs/examples/index.md @@ -0,0 +1,54 @@ +--- +title: Examples +description: Three small packages modelling one billing domain — code that compiles and is covered by tests, unlike the snippets in the guide. +--- + +# Examples + +Annotated tours of the runnable packages under +[`examples/`](https://github.com/btravstack/entity/tree/main/examples). They +model one small billing domain between them, each showing a different job. + +**Unlike the snippets elsewhere in this guide, this code compiles and is covered +by tests.** One of the three goes further: `billing-domain` is the fixture +proving a downstream library can emit its own declarations against this package. + +Nothing needs installing and nothing needs to be listening: + +```sh +pnpm install +pnpm test +``` + +## [Billing domain](/examples/billing-domain) + +Declaring the entities: branded fields, `generated` / `immutable` / `computed`, +invariants as values, one entity nested inside another, a discriminated union, +and factories binding the id and clock the package refuses to read for itself. + +## [HTTP contract](/examples/billing-api) + +The four plain `ZodObject`s composed into an oRPC contract and converted to JSON +Schema in both directions — and the class deliberately refusing to convert, +because it parses to an instance rather than to data. + +## [Persistence](/examples/billing-persistence) + +`toJSON()` out, `make()` back, over an in-memory store: the round trip, the +absent `_tag`, and a corrupt row arriving as a `Result` rather than a throw. + +## Why these exist as packages rather than snippets + +Every fenced block in the rest of this guide is written by hand. It is checked +by review and nothing else, so it can drift from the library without any build +noticing. + +These three cannot. They are workspace packages: they typecheck, their specs +run in CI, and they consume `@btravstack/entity` through its real published +entry point rather than a path alias. If the library changes underneath them, +something goes red. + +That property is not theoretical. Writing `billing-domain` immediately surfaced +a declaration-emit bug — an exported `const` holding `Entity.union(...)` failed +with `TS4023` — that the package's own test suite could not see, because +`vitest` never typechecks and the bug lived only in emitted declarations. From 16f99769e42fc771ad772f09ba97c98cd949359f Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 7 Aug 2026 23:45:01 +0200 Subject: [PATCH 6/6] fix(examples): discriminate the union on a declared field, not _tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Entity.union("_tag", …)` could never match anything. `_tag` is non-enumerable, so it is absent from toJSON() and from every row, which means discriminantValues() registered no keys and make() rejected every payload with: [{"path":["_tag"],"message":"Invalid discriminant undefined; expected one of "}] — an empty set. union.ts documents this exact prohibition; the example did the opposite, and the spec never called make() through the union, so nothing caught it. Raised in review on #36. Replaced with the modelling the union is for: CreditNote joins Invoice as a sibling document, both declaring `kind` as a generated literal, and BillingDocument dispatches on that. Four specs now cover it — both members round-tripping to the right class, the discriminant surviving toJSON, and an unknown value reporting a populated "expected one of". The trap is written down in the package README and the docs page, since reaching for _tag is the obvious wrong move and it fails silently. --- docs/examples/billing-domain.md | 33 ++++++++++++++ examples/billing-domain/README.md | 8 +++- examples/billing-domain/src/emit-guards.ts | 4 +- examples/billing-domain/src/index.spec.ts | 50 +++++++++++++++++++++ examples/billing-domain/src/index.ts | 51 ++++++++++++++++++++-- 5 files changed, 138 insertions(+), 8 deletions(-) diff --git a/docs/examples/billing-domain.md b/docs/examples/billing-domain.md index dbcb218..2e432d0 100644 --- a/docs/examples/billing-domain.md +++ b/docs/examples/billing-domain.md @@ -113,4 +113,37 @@ declaration emit walks it. An **unused** `@ts-expect-error` in that file is a failure rather than noise, because a namespace member emitted as a circular self-alias still compiles and simply degenerates. +## The union discriminates data, not instances + +```ts +export const BillingDocument = Entity.union("kind", [ + Invoice, + CreditNote, +] as const); +``` + +`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. + +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 +is absent from `toJSON()` and from anything that has been through JSON. A union +built on it registers no members and rejects every payload with + +``` +Invalid discriminant undefined; expected one of +``` + +— an empty set. This example shipped that exact bug for one commit, because the +spec never called `make()` through the union. The specs now do, which is the +only reason it is not still there. + +The two mechanisms are complementary, not alternatives: + +| | Discriminates | Use | +| ---------------- | -------------------------------------- | ------------------------- | +| A declared field | **data** arriving from a wire or a row | `Entity.union("kind", …)` | +| `_tag` | an **instance** you already hold | `P.tag("Invoice")` | + Related reference: [Declaring an entity](/reference/declaration). diff --git a/examples/billing-domain/README.md b/examples/billing-domain/README.md index 87dc9ac..8c9700b 100644 --- a/examples/billing-domain/README.md +++ b/examples/billing-domain/README.md @@ -25,8 +25,12 @@ pnpm --filter @btravstack/entity-example-billing-domain typecheck `Result`, never an exception. - **Nesting.** `Invoice.issuedTo` is an `Organization` — the class is itself a zod schema, so it parses back to a real instance with its behaviour intact. -- **A union**, dispatching on the declared discriminant so a failing member - reports its own issues. +- **A union** over `Invoice` and `CreditNote`, dispatching on `kind` — a + _declared_ field, never `_tag`. `_tag` is non-enumerable and absent from + `toJSON()`, so a union built on it matches nothing and says so with an empty + `expected one of` set. This package shipped that bug for exactly one commit; + the specs now call `make()` through the union, which is what would have caught + it. - **Factories.** The package reads no clock and generates no id; a factory is where those come in, bound once. That is what leaves the entities trivially testable. diff --git a/examples/billing-domain/src/emit-guards.ts b/examples/billing-domain/src/emit-guards.ts index 8961894..6ef249d 100644 --- a/examples/billing-domain/src/emit-guards.ts +++ b/examples/billing-domain/src/emit-guards.ts @@ -34,7 +34,7 @@ import type { z } from "zod"; // `Organization` is imported as a value: the sealed-construction assertion // below needs the runtime binding to write `new Organization(...)` at all. import { Organization } from "./index.js"; -import type { DisplayLabel, Invoice, Slug } from "./index.js"; +import type { CreditNote, DisplayLabel, Invoice, Slug } from "./index.js"; /* ── Construction stays sealed from outside the package ───────────────── */ @@ -71,7 +71,7 @@ export type Static = Entity.Static< [], [] >; -export type Members = Entity.Union<"_tag", [typeof Organization, typeof Invoice]>; +export type Members = Entity.Union<"kind", [typeof Invoice, typeof CreditNote]>; /** The error is reachable as both a value and a type. */ export const isInvalid = (error: unknown): error is Entity.InvalidEntity => diff --git a/examples/billing-domain/src/index.spec.ts b/examples/billing-domain/src/index.spec.ts index 13c825c..ab6ad38 100644 --- a/examples/billing-domain/src/index.spec.ts +++ b/examples/billing-domain/src/index.spec.ts @@ -1,11 +1,16 @@ +import { P } from "unthrown"; import { expect, test } from "vitest"; import { + BillingDocument, + CreditNote, DisplayName, Invoice, + InvoiceId, Money, Organization, Slug, + createCreditNote, createInvoice, createOrganization, } from "./index.js"; @@ -89,3 +94,48 @@ test("a branded object field keeps its members", () => { test("a malformed row comes back as an error, not an exception", () => { expect(Organization.make({ slug: "", name: "" }).isErr()).toBe(true); }); + +/* ── The union dispatches on a DECLARED field, never on `_tag` ────────── + These four are the tests whose absence let a broken union ship: the first + version of this file discriminated on "_tag", which is non-enumerable and + therefore missing from every row, so `make` rejected everything with an + empty "expected one of " set. Nothing noticed, because nothing called it. */ + +test("the union makes the right class from a row", async () => { + const invoiceRow = invoice().toJSON(); + const made = BillingDocument.make(invoiceRow).getOrThrow(); + + expect(made).toBeInstanceOf(Invoice); + expect(made).not.toBeInstanceOf(CreditNote); +}); + +test("the union dispatches to the other member on the other value", () => { + const note = createCreditNote({ + issuedTo: org(), + against: InvoiceId.parse("33333333-3333-4333-8333-333333333333"), + total: money(500, "EUR"), + }).getOrThrow(); + + const made = BillingDocument.make(note.toJSON()).getOrThrow(); + expect(made).toBeInstanceOf(CreditNote); +}); + +test("the discriminant survives toJSON, which is why it is a declared field", () => { + const row = invoice().toJSON(); + + expect(row.kind).toBe("INVOICE"); + // `_tag` does NOT survive — a union built on it could never match a row. + expect("_tag" in row).toBe(false); +}); + +test("an unknown discriminant is a reported error, not a silent miss", async () => { + const message = await BillingDocument.make({ kind: "PROFORMA" }).match({ + ok: () => "ok", + errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues[0]?.message ?? ""), + defect: () => "defect", + }); + + expect(message).toContain("Invalid discriminant"); + expect(message).toContain('"INVOICE"'); + expect(message).toContain('"CREDIT_NOTE"'); +}); diff --git a/examples/billing-domain/src/index.ts b/examples/billing-domain/src/index.ts index aabefab..2e47230 100644 --- a/examples/billing-domain/src/index.ts +++ b/examples/billing-domain/src/index.ts @@ -17,6 +17,7 @@ import { z } from "zod"; export const OrganizationId = z.uuid().brand("OrganizationId"); export const InvoiceId = z.uuid().brand("InvoiceId"); +export const CreditNoteId = z.uuid().brand("CreditNoteId"); export const Slug = z.string().min(1).max(40).brand("Slug"); export const DisplayName = z.string().min(1).brand("DisplayName"); export const DisplayLabel = z.string().min(1).brand("DisplayLabel"); @@ -123,6 +124,7 @@ export class Organization extends Entity("Organization")( export class Invoice extends Entity("Invoice")( { id: InvoiceId, + kind: z.literal("INVOICE"), issuedTo: Organization, lines: z.array(LineItem), total: Money, @@ -132,8 +134,8 @@ export class Invoice extends Entity("Invoice")( issuedAt: Instant, }, { - generated: ["id", "issuedAt"], - immutable: ["id", "issuedAt", "issuedTo"], + generated: ["id", "issuedAt", "kind"], + immutable: ["id", "issuedAt", "issuedTo", "kind"], invariants: [ Entity.invariant((d) => d.total.amount >= 0, "total must not be negative"), Entity.invariant( @@ -148,8 +150,39 @@ export class Invoice extends Entity("Invoice")( } } -/** Dispatches on the declared discriminant, so a failing member reports its own issues. */ -export const BillingRecord = Entity.union("_tag", [Organization, Invoice] as const); +/** + * A credit note is an invoice's sibling, not its subtype: same counterparty and + * money, opposite direction, its own identity. Modelling it as a second entity + * sharing the `kind` discriminant is what lets both travel down one channel and + * come back as the right class. + */ +export class CreditNote extends Entity("CreditNote")( + { + id: CreditNoteId, + kind: z.literal("CREDIT_NOTE"), + issuedTo: Organization, + against: InvoiceId, + total: Money, + issuedAt: Instant, + }, + { + generated: ["id", "issuedAt", "kind"], + immutable: ["id", "issuedAt", "issuedTo", "against", "kind"], + invariants: [Entity.invariant((d) => d.total.amount >= 0, "total must not be negative")], + }, +) {} + +/** + * Dispatches on `kind` — a **declared domain field**, never the entity's + * `_tag`. That distinction is the whole design: `_tag` is non-enumerable, so it + * is absent from `toJSON()` and from anything that has been through JSON, and a + * union built on it would register no members and reject every payload with + * "expected one of " — an empty set. + * + * The two mechanisms are not redundant. This field discriminates **data** on + * the way in; `P.tag(...)` matches an **instance** you already hold. + */ +export const BillingDocument = Entity.union("kind", [Invoice, CreditNote] as const); /* ── Binding the effect sources ──────────────────────────────────────── The package reads no clock and generates no id. A factory is where those @@ -166,4 +199,14 @@ export const createOrganization = Organization.factory({ export const createInvoice = Invoice.factory({ id: () => crypto.randomUUID() as z.infer, 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 + // happen, and `generated` keeps it out of `createInput` entirely. + kind: () => "INVOICE" as const, +}); + +export const createCreditNote = CreditNote.factory({ + id: () => crypto.randomUUID() as z.infer, + issuedAt: now, + kind: () => "CREDIT_NOTE" as const, });