Skip to content

refactor: replace the consumer fixture with an examples workspace - #36

Merged
btravers merged 6 commits into
fix/declaration-emit-and-zod-peerfrom
feat/examples-workspace
Aug 7, 2026
Merged

refactor: replace the consumer fixture with an examples workspace#36
btravers merged 6 commits into
fix/declaration-emit-and-zod-peerfrom
feat/examples-workspace

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Replaces packages/entity/consumer/ with a root examples/ workspace — the convention amqp-contract, temporal-contract and demesne already use — so the same guarantee is carried by code that also documents the library.

Stacked on #35. Base is fix/declaration-emit-and-zod-peer, because the emit fixture needs the EntityStatic export to exist. Merge that first.

What consumer/ was, and why this is not a downgrade

It proved one thing: a downstream library compiling with declaration: true can build against this package. It did that well, and documented nothing.

examples/billing-domain takes the role over and is stricter in one respect — it depends on @btravstack/entity as workspace:* and resolves dist/index.d.mts through the package's real exports, where the old fixture faked it with a paths mapping.

The migration is only safe if the new fixture still fails when it should, so that is verified rather than assumed:

Export removed billing-domain reports
EntityStatic TS4020 + TS7056
EntityUnion TS4023

It found a bug before it was finished

Writing the domain immediately surfaced TS4023 on an exported const holding Entity.union(...) — the second error reported in #32, which the EntityStatic fix alone did not cover. EntityUnion and UnionMember are now exported too. That commit is cherry-picked onto #35 so it closes #32 properly.

This is the argument for the whole change in one incident: the package's own 145-test suite could not see it, because vitest never typechecks and the bug lived only in emitted declarations.

Correcting something in #35's description

#35 says the 7.x native port "does not enforce" TS7056. That is wrong, and I've corrected it in CLAUDE.md, pnpm-workspace.yaml and examples/README.md. Both versions enforce it — 5.9.3's threshold is simply lower:

TypeScript 7.0.2 TypeScript 5.9.3
Narrower entity TS4020 only TS4020 + TS7056
Wider entity (Invoice) TS4020 + TS7056 TS4020 + TS7056

The second pass still earns its cost, for a sharper reason: there is a band of realistic domain widths that fails for consumers on 5.x and passes on the version this repo builds with. That band is where #31 and #32 lived.

The three packages

Package Shows
billing-domain Branded fields, a branded Money object, generated/immutable/computed, invariants, nesting, a union, factories. Carries the emit passes.
billing-api The four ZodObjects → oRPC contract + JSON Schema both ways, and the class refusing to convert.
billing-persistence toJSON() out, make() back, InvalidEntity | OrganizationNotFound, no try/catch.

No Docker, no broker, no database, no server — pnpm test runs all of it. amqp's examples need RabbitMQ and demesne's needs Prisma; entity does no I/O, so there was no reason to inherit that.

Two things kept deliberately odd, both flagged inline because they invite tidying:

  • The dunning enum stays at 30 members. TS7056 is a threshold on serialised characters. My first fixture attempt was narrower, compiled fine unfixed, and guarded nothing.
  • emit-guards.ts is not example code and says so — a forged construction key is not a pattern to copy. Isolating it is what keeps index.ts readable as documentation.

Notes for review

Verification

Full gate green: format --check, lint, typecheck (6 tasks), test (5 tasks, 163 specs), knip, build. Docs site builds with dead-link checking on.

🤖 Generated with Claude Code

Benoit Travers added 5 commits August 7, 2026 23:18
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.
…/entity

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.
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.
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.
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the declaration-emit “consumer” fixture into a set of runnable examples/ workspace packages, so the same downstream-emit guarantees are exercised by code that also documents how to use @btravstack/entity.

Changes:

  • Replace packages/entity/consumer/ + its consumer tsconfigs with examples/billing-domain, which runs declaration emit twice (TS 7.0.2 and 5.9.3).
  • Add two additional example workspaces (billing-api, billing-persistence) with Vitest coverage, plus documentation pages and sidebar integration.
  • Extend the monorepo workspace + catalog deps (oRPC packages) and update knip configuration to account for the new fixture layout.

Reviewed changes

Copilot reviewed 34 out of 35 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pnpm-workspace.yaml Adds examples/* as a workspace and catalogs oRPC deps used by the examples.
pnpm-lock.yaml Captures the new example workspaces and their dependency graph.
packages/entity/tsconfig.consumer5.json Removes the old consumer TS 5.x emit config (fixture moved to examples/).
packages/entity/tsconfig.consumer.json Removes the old consumer emit config (fixture moved to examples/).
packages/entity/package.json Drops consumer emit passes from this workspace’s typecheck and removes typescript-consumer devDep.
packages/entity/consumer/index.ts Deletes the old downstream “library emits declarations” fixture.
knip.jsonc Introduces updated knip config and pins examples/billing-domain/src/emit-guards.ts as an entry.
knip.json Removes the prior knip config file (replaced by knip.jsonc).
examples/README.md Adds an overview of the runnable example packages and how they participate in CI/typecheck.
examples/billing-domain/package.json New example package that also serves as the declaration-emit fixture (dual-TS emit).
examples/billing-domain/README.md Explains what billing-domain demonstrates and what’s intentionally “odd” in the fixture.
examples/billing-domain/tsconfig.json Base TS config for the example package.
examples/billing-domain/tsconfig.emit.json Declaration-only emit config used to verify downstream .d.ts portability.
examples/billing-domain/vitest.config.ts Vitest configuration for the example package.
examples/billing-domain/src/index.ts Implements a small billing domain using entities, unions, factories, and branded fields.
examples/billing-domain/src/index.spec.ts Runtime-focused tests for the example domain behavior and projections.
examples/billing-domain/src/emit-guards.ts Compile-time “emit guards” (ts-expect-error assertions) replacing the old consumer fixture.
examples/billing-api/package.json New example package showing contract composition + JSON Schema conversion.
examples/billing-api/README.md Documents the contract/JSON Schema example and the rationale for explicit type naming.
examples/billing-api/tsconfig.json Base TS config for the API example package.
examples/billing-api/vitest.config.ts Vitest configuration for the API example package.
examples/billing-api/src/index.ts Demonstrates composing entity ZodObjects into an oRPC contract and JSON Schemas.
examples/billing-api/src/index.spec.ts Tests schema conversion behavior and contract surface.
examples/billing-persistence/package.json New example package showing persistence/rehydration and error modelling.
examples/billing-persistence/README.md Documents the persistence example and the “two errors, not one” distinction.
examples/billing-persistence/tsconfig.json Base TS config for the persistence example package.
examples/billing-persistence/vitest.config.ts Vitest configuration for the persistence example package.
examples/billing-persistence/src/index.ts Implements an in-memory repository demonstrating toJSON() + make() round-trip.
examples/billing-persistence/src/index.spec.ts Tests storage round trip, _tag absence, corrupt row handling, and immutability on update.
docs/examples/index.md Adds an examples landing page in the docs site.
docs/examples/billing-domain.md Adds the docs walkthrough for the billing domain example.
docs/examples/billing-api.md Adds the docs walkthrough for the HTTP contract example.
docs/examples/billing-persistence.md Adds the docs walkthrough for the persistence example.
docs/.vitepress/config.ts Adds “Examples” to nav and injects an examples section into the shared sidebar.
CLAUDE.md Updates repo guidance to reflect the new examples workspaces and corrected TS7056 behavior notes.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread examples/billing-domain/src/index.ts Outdated
`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.
@btravers
btravers merged commit 1396138 into fix/declaration-emit-and-zod-peer Aug 7, 2026
@btravers
btravers deleted the feat/examples-workspace branch August 7, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants