Skip to content

fix: declaration emit for consumers, wider zod peer range, and an examples workspace - #35

Merged
btravers merged 9 commits into
mainfrom
fix/declaration-emit-and-zod-peer
Aug 7, 2026
Merged

fix: declaration emit for consumers, wider zod peer range, and an examples workspace#35
btravers merged 9 commits into
mainfrom
fix/declaration-emit-and-zod-peer

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes the three issues from a real adoption — #31, #32, #33 — and replaces the consumer fixture with a runnable examples/ workspace (#36, merged in).

Note on the Copilot review below: it was written before #36 merged into this branch and is now stale. It describes tsconfig.consumer5.json as added and packages/entity/consumer/index.ts as extended; both have since been deleted, with the fixture moved to examples/billing-domain. Nine files reviewed then, 38 now.

One root cause behind #31 and #32

EntityStatic — what Entity(tag)(fields, options) returns — was not exported. TypeScript had no name to write for it at a consumer's emit site, so it serialised the entire static surface structurally into any downstream package building with declaration: true: the construct signature, all four ZodObjects, both zod slots, the four phantom carriers, make/extend/factory — with the field map repeated a dozen times.

before after
.d.ts emitted for a one-field entity 274,048 bytes 240 bytes

All three surfaced only at the consuming package's build, long after tsc --noEmit, the tests and everything else went green. Branded objects now work and stay deep-readonly — the "model it as a nested entity" workaround is no longer needed.

#33 — zod peer range

Widened ^4.4.0^4.3.0. Nothing needed 4.4; the range was just the version current at the initial release. The floor is measured: on 4.3.0 the full surface typechecks, emits declarations and passes its runtime assertions.

Pinning zod in the adopting package alone does not work, and there is a concrete reason — zod encodes its own minor version in the type (_zod.version.minor), so two resolved copies fail at the boundary with TS2322.

The gate that let this ship

The declaration-emit pass now runs twice, on 7.0.2 and on 5.9.3 via a typescript-consumer alias. Both versions enforce TS7056; 5.9.3's threshold is lower:

TypeScript 7.0.2 TypeScript 5.9.3
Narrower entity TS4020 only TS4020 + TS7056
Wider entity TS4020 + TS7056 TS4020 + TS7056

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

The examples workspace (#36)

packages/entity/consumer/ proved a downstream library could build against this package, and documented nothing. examples/billing-domain does both, and resolves @btravstack/entity through its real exports as workspace:* rather than the old paths mapping. Two siblings — billing-api (the four ZodObjects → oRPC contract + JSON Schema) and billing-persistence (toJSON() out, make() back) — put the design rule into the directory structure.

No Docker, no broker, no database: pnpm test runs all of it.

It found two bugs while being written, which is the argument for it: the TS4023 union export above, and a broken Entity.union("_tag", …) in the example itself — _tag is non-enumerable, so it is absent from every row and the union matched nothing. Both invisible to the 145-test suite, because vitest never typechecks and one of them lived only in emitted declarations.

Verification

Full gate green on the merged branch, CI included, across Node 22 / 24 / 26: format --check, lint, typecheck (6 tasks), test (167 specs), knip, build.

The migration's acceptance test — remove an export, confirm the new fixture still fails:

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

Beyond the gate, the packed tarball was installed into a standalone project on TypeScript 5.9.3 and exercised on zod 4.3.0 and 4.4.3 — declaration emit clean for the wide-enum entity, the branded-object entity and the full documented surface, runtime assertions passing on both.

🤖 Generated with Claude Code

`EntityStatic` — what `Entity(tag)(fields, options)` returns — was not
exported, so TypeScript had no name to write for it and serialised the
whole static surface structurally into every downstream package building
with `declaration: true`, repeating the field map a dozen times. A
one-field entity emitted a 274,048-byte declaration; it is now 240.

That expansion was two reported build failures, not verbosity:

- a 30-member domain enum pushed the repeated field map past the
  compiler's serialisation ceiling (TS7056, #31);
- a branded object field was expanded through `DeepReadonly` until zod's
  module-private `$brand` reached computed-key position, where it cannot
  be named across a module boundary (TS4020, #32).

Both surfaced only at the consumer's build, after everything else was
green. Pinned by the consumer fixture, whose wide-enum entity is held at
the reported width on purpose — trimming it drops back under the ceiling
and it silently stops guarding.

The consumer declaration-emit pass now runs twice, adding TypeScript
5.9.3 through a `typescript-consumer` alias. The repo's own 7.0.2 native
port does not enforce the 5.x serialised-length ceiling, which is exactly
why TS7056 shipped while this gate stayed green.

The zod peer range widens to ^4.3.0. Nothing needed 4.4 — the range was
just the version current at the initial release — and the floor is
measured: full surface typechecks, emits and passes its runtime
assertions on 4.3.0 (#33).

Closes #31
Closes #32
Closes #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 7, 2026 20:42

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

This PR addresses downstream TypeScript declaration-emit failures by ensuring EntityStatic is emitted by reference (not structurally expanded) and by tightening the project’s consumer-compatibility checks to include a real TypeScript 5.x pass. It also widens the zod peer dependency range to match the measured minimum supported minor.

Changes:

  • Export EntityStatic as a top-level type and expose it as Entity.Static for consumers that hand-annotate.
  • Add a second consumer declaration-emit pass using a typescript-consumer alias pinned to TS 5.9.3 (to catch TS7056 that TS 7.0.2 doesn’t enforce).
  • Widen zod peer range from ^4.4.0 to ^4.3.0, with documentation explaining the measured floor.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.

Show a summary per file
File Description
pnpm-workspace.yaml Adds a typescript-consumer catalog alias for TS 5.9.3 to enable dual TypeScript passes.
pnpm-lock.yaml Locks the new typescript-consumer alias resolution and TS 5.9.3 package entry.
packages/entity/tsconfig.consumer5.json New config extending the consumer emit config, with a separate outDir for the TS 5.x pass.
packages/entity/src/index.ts Exports EntityStatic from types.ts to ensure consumers can emit by reference.
packages/entity/src/entity.ts Adds Entity.Static namespace alias for EntityStatic via the existing *Src indirection pattern.
packages/entity/README.md Documents the widened zod peer range and rationale (measured floor, monorepo ergonomics).
packages/entity/package.json Updates typecheck to run both consumer passes; adds typescript-consumer; widens zod peer range.
packages/entity/consumer/index.ts Extends the consumer fixture to exercise Entity.Static and pin prior TS4020/TS7056 regressions.
CLAUDE.md Updates repo guidance to reflect the new four-pass typecheck gate behavior.
.changeset/wide-entity-declaration-emit.md Adds a changeset describing the declaration-emit fix and peer range change for release notes.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

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

Benoit Travers and others added 6 commits August 7, 2026 23:17
An exported `const` holding `Entity.union(...)` hit the same root cause as
EntityStatic one type further along: with no top-level name for what it
returns, TypeScript expanded the union's members structurally and reached
zod's module-private `$brand` through any branded field —

  TS4023: Exported variable 'BillingRecord' has or is using name '$brand'
  from external module ".../zod/v4/core/core" but cannot be named.

That is the second error reported in #32, which the EntityStatic export
alone did not cover. `UnionMember` travels with it as EntityUnion's own
constraint. Isolated by removing the export and watching TS4023 return.

Refs #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
@btravers btravers changed the title fix: emit EntityStatic by reference, and widen the zod peer range fix: emit EntityStatic and EntityUnion by reference, and widen the zod peer range Aug 7, 2026
@btravers

btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the original description, since it stated this the wrong way round.

I wrote that the 7.x native port "does not enforce" TS7056, so the limit was invisible to it. That is not what happens. Both versions enforce it — 5.9.3's threshold is simply lower.

Measured on the same code, only the entity's width changing:

TypeScript 7.0.2 TypeScript 5.9.3
Narrower entity TS4020 only TS4020 + TS7056
Wider entity TS4020 + TS7056 TS4020 + TS7056

The second pass still earns its cost, and the real reason is sharper than the one I gave: there is a band of realistic domain widths that fails for a consumer on 5.x and passes on the version this repo builds with. That band is exactly where #31 and #32 came from.

The description above is updated, and CLAUDE.md, pnpm-workspace.yaml and examples/README.md carry the corrected version.

Benoit Travers and others added 2 commits August 7, 2026 23:45
`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.
refactor: replace the consumer fixture with an examples workspace
@btravers btravers changed the title fix: emit EntityStatic and EntityUnion by reference, and widen the zod peer range fix: declaration emit for consumers, wider zod peer range, and an examples workspace Aug 7, 2026
@btravers

btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

The Copilot review above predates #36 and is now stale. Flagging rather than leaving it to mislead a reader.

It reviewed 9 files; this PR now carries 38. Three specific claims in it are no longer true:

Review says Actual state
packages/entity/tsconfig.consumer5.json — new config deleted
packages/entity/tsconfig.consumer.json deleted
packages/entity/consumer/index.ts — fixture extended deleted

The declaration-emit fixture moved to examples/billing-domain, which does the same job through the package's real exports instead of a paths mapping.

Its summary line "to catch TS7056 that TS 7.0.2 doesn't enforce" also repeats the description's original error, which I have since corrected: both versions enforce TS7056, 5.9.3's threshold is simply lower. Details in the comment above.

The review on #36 was the substantive one — it caught a genuine defect (Entity.union("_tag", …) matching nothing), fixed in 16f9976 before that PR merged.

@btravers
btravers requested a lite review from Copilot August 7, 2026 21:48
@btravers
btravers merged commit 06ccdc4 into main Aug 7, 2026
14 checks passed

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

Copilot reviewed 37 out of 38 changed files in this pull request and generated no new comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Generated file
Suppressed comments (3)

examples/billing-persistence/package.json:15

  • This workspace imports @btravstack/entity via its real exports (pointing at dist/*). Since packages/entity/dist is generated and not checked in, running pnpm --filter @btravstack/entity-example-billing-persistence test from a clean repo can fail unless @btravstack/entity has been built first. Prepending an explicit build makes this example runnable in isolation.
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },

examples/billing-domain/package.json:15

  • The example package imports @btravstack/entity via its real exports (which point at dist/*). Since dist/ is not in the repo, running this workspace’s test/typecheck directly (e.g. pnpm --filter @btravstack/entity-example-billing-domain test) will fail unless the entity package has been built first. Prepend an explicit pnpm --filter @btravstack/entity build so the package is runnable in isolation as the README suggests.
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json"
  },

examples/billing-api/package.json:15

  • This workspace imports @btravstack/entity through its published entrypoints (which resolve to dist/*). Because dist/ isn’t present until the entity package is built, pnpm --filter @btravstack/entity-example-billing-api test can fail when run from a clean checkout. Consider building @btravstack/entity as part of the script so the example package is runnable on its own.
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit"
  },

@btravers
btravers deleted the fix/declaration-emit-and-zod-peer branch August 7, 2026 23:20
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