Skip to content

fix!: let a consumer emitting declarations compile against the seal - #13

Merged
btravers merged 3 commits into
mainfrom
fix/consumer-declaration-emit
Aug 6, 2026
Merged

fix!: let a consumer emitting declarations compile against the seal#13
btravers merged 3 commits into
mainfrom
fix/consumer-declaration-emit

Conversation

@btravers

@btravers btravers commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Any downstream library — anything compiling with declaration: true
could not use this package at all:

TS4020: 'extends' clause of exported class 'Organization' has or is using
        private name 'BaseInstance'.
TS4020: 'extends' clause of exported class 'Organization' has or is using
        private name 'CtorKey'.

Leaf applications were fine, which is why nothing caught it: this repo's own
tsc pass is noEmit, so the package could never surface the failure its own
consumers 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 symbol used as a
computed key. A unique symbol in that position cannot be named across a
module boundary even when exported — measured; exporting it alone does not
help. An ordinary named property whose type is an exported class can be, so:

export declare class ConstructionKey {
  private constructor();
  private readonly seal: never;
}
export type Sealed<D> = D & { readonly __constructionKey: ConstructionKey };

ConstructionKey, Sealed and BaseInstance are now exported as types purely
so emitted declarations can reference them. The consumer's .d.ts names them
as import("@btravstack/entity").Sealed<…> and compiles.

The seal is not weakened

Verified from a consumer, against the built types:

new Organization({…}) TS2345 — still sealed
Organization.make(raw): Organization compiles — statics still yield the subclass
const k: ConstructionKey = {} as { seal: never } TS2322 — unforgeable structurally

ConstructionKey has a private constructor and a private field, so it cannot
be produced structurally, and it has no runtime existence.

Regression guard

packages/entity/consumer/ is a fixture that exports an entity subclass and is
compiled by tsconfig.consumer.json with declaration emit, against dist
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's
typecheck now dependsOn: ["build"] because the fixture needs the built
types.

I confirmed the guard actually fails on the bug rather than merely passing on
the fix: restoring the old unique symbol seal produces exactly 2 × TS4020.

Note

types.ts previously claimed the seal "depends on declaration: false in the
shared tsconfig". That was already stale — the shared base sets declaration: true with noEmit: true — and the comment is replaced with the measured
behaviour, including why private/protected constructors (TS2675 / TS2684)
are not alternatives.

Gate

format --check, lint, typecheck (three passes now), test (94, 9 files),
knip, build — all green.

Copilot AI lite review requested due to automatic review settings August 6, 2026 23: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 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-unconstructable ConstructionKey, and updated Sealed to reference it so emitted declarations can name the seal types across module boundaries.
  • Exported BaseInstance, ConstructionKey, and Sealed (type-only) from the public entrypoint so consumer declaration output can reference them.
  • Added a packages/entity/consumer/ fixture compiled with declaration emit against dist and wired it into typecheck, with Turbo updated so typecheck depends on build.

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.

Comment thread packages/entity/src/index.ts Outdated
Comment thread packages/entity/tsconfig.consumer.json
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