fix!: let a consumer emitting declarations compile against the seal - #13
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a TypeScript declaration-emit regression that prevented downstream libraries (projects compiling with declaration: true) from exporting entity subclasses due to TS4020 private-name leakage in the construction seal types, and adds a consumer-style fixture to prevent regressions.
Changes:
- Replaced the module-private
unique symbol-based seal with an exported-but-unconstructableConstructionKey, and updatedSealedto reference it so emitted declarations can name the seal types across module boundaries. - Exported
BaseInstance,ConstructionKey, andSealed(type-only) from the public entrypoint so consumer declaration output can reference them. - Added a
packages/entity/consumer/fixture compiled with declaration emit againstdistand wired it intotypecheck, with Turbo updated sotypecheckdepends onbuild.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| turbo.json | Ensures typecheck runs after build so the consumer fixture can compile against built declaration outputs. |
| packages/entity/tsconfig.consumer.json | Adds a declaration-emitting consumer tsconfig that resolves @btravstack/entity via dist/index.d.mts. |
| packages/entity/src/types.ts | Introduces ConstructionKey, updates Sealed to use a nameable exported type, and exports BaseInstance to avoid TS4020 in consumer emits. |
| packages/entity/src/index.ts | Re-exports the new public type surface (BaseInstance, ConstructionKey, Sealed) for consumer declaration naming. |
| packages/entity/package.json | Extends typecheck to include the consumer declaration-emit pass. |
| packages/entity/consumer/index.ts | Adds a minimal exported-entity consumer fixture plus @ts-expect-error assertions for seal strength. |
| .changeset/consumer-declaration-emit.md | Documents the consumer declaration-emit fix and the newly exported type-only surface. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
btravers
commented
Aug 6, 2026
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Any downstream library — anything compiling with
declaration: true—could not use this package at all:
Leaf applications were fine, which is why nothing caught it: this repo's own
tscpass isnoEmit, so the package could never surface the failure its ownconsumers hit. Reproduced against the built
dist/*.d.mts, not from reasoning.It also triggers when the entity class is not exported — merely appearing in an
exported function's inferred return type is enough.
The fix
The seal was a module-private
declare const CtorKey: unique symbolused as acomputed key. A
unique symbolin that position cannot be named across amodule boundary even when exported — measured; exporting it alone does not
help. An ordinary named property whose type is an exported class can be, so:
ConstructionKey,SealedandBaseInstanceare now exported as types purelyso emitted declarations can reference them. The consumer's
.d.tsnames themas
import("@btravstack/entity").Sealed<…>and compiles.The seal is not weakened
Verified from a consumer, against the built types:
new Organization({…})TS2345— still sealedOrganization.make(raw): Organizationconst k: ConstructionKey = {} as { seal: never }TS2322— unforgeable structurallyConstructionKeyhas a private constructor and a private field, so it cannotbe produced structurally, and it has no runtime existence.
Regression guard
packages/entity/consumer/is a fixture that exports an entity subclass and iscompiled by
tsconfig.consumer.jsonwith declaration emit, againstdist—the only configuration that reproduces this. It runs as the third step of
pnpm typecheck, so the existing Type Check CI job covers it;turbo'stypechecknowdependsOn: ["build"]because the fixture needs the builttypes.
I confirmed the guard actually fails on the bug rather than merely passing on
the fix: restoring the old
unique symbolseal produces exactly 2 × TS4020.Note
types.tspreviously claimed the seal "depends ondeclaration: falsein theshared tsconfig". That was already stale — the shared base sets
declaration: truewithnoEmit: true— and the comment is replaced with the measuredbehaviour, including why
private/protectedconstructors (TS2675 / TS2684)are not alternatives.
Gate
format --check,lint,typecheck(three passes now),test(94, 9 files),knip,build— all green.