Skip to content

fix(di): let consumers emit declarations for their own ports - #1

Merged
btravers merged 3 commits into
mainfrom
fix/port-declaration-emit
Aug 9, 2026
Merged

fix(di): let consumers emit declarations for their own ports#1
btravers merged 3 commits into
mainfrom
fix/port-declaration-emit

Conversation

@btravers

@btravers btravers commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Exporting a port you declare — the pattern packages/di/README.md teaches — could not emit declarations. Any consumer with declaration: true, which is the shared tsconfig default, got:

error TS4020: 'extends' clause of exported class 'OrderRepository' has or is using private name 'ID'.
error TS4020: 'extends' clause of exported class 'OrderRepository' has or is using private name 'SERVICE'.

ID, SERVICE and MANY are module-private unique symbols carried by PortInstance, so TypeScript could not write the consumer's .d.ts.

The fix

One line in packages/di/src/index.ts: re-export PortClass and ManyPortClass as types. The emitter can then name the heritage expression instead of expanding it to the brands. port.ts is untouched and the symbols stay unexported:

declare const OrderRepository_base: import("@btravstack/di").PortClass<"OrderRepository">;
export declare class OrderRepository extends OrderRepository_base<{ readonly find: () => string }> {}

Nominal identity is unaffected

That was the constraint that ruled out the alternatives. Two structurally identical ports with different ids still do not unify (port.test-d.ts unchanged and green), the brands are still not importable, and a hand-written object is still rejected — [ID], [SERVICE] missing.

Rejected candidates, each measured rather than reasoned about:

candidate why not
export the ambient symbols rolldown MISSING_EXPORT, build fails
export real Symbol() values emit works, but a port becomes forgeable by hand
export instance types + a new ManyPortInstance works, but adds a public alias and emits 3,545 vs 2,683 bytes

Regression guard

examples/hexagonal-order-api/src/emit-guards.ts plus tsconfig.emit.json, mirroring the arrangement in btravstack/entity. It emits under TypeScript 7.0.2 and 5.9.3, then type-checks the emitted .d.ts, and is wired into that package's typecheck script so pnpm typecheck runs it. Verified with teeth: reverting the index re-export puts 11 TS4020/TS4023 errors back. knip.jsonc names it an entry — also verified load-bearing.

The declaration: false workaround is removed from all three example tsconfigs; it was hiding this.

Verification

lint, oxfmt --check, typecheck 5/5, test 5/5 (48 tests), test:types 3/3, build, knip — all clean, forced past turbo's cache. The reproduction was re-run against the rebuilt dist/ by hand: emit exit 0 for a plain port, a Port.many set port, and a port whose shape references another port's ServiceOf.

Not verified: consumer-side bundler .d.ts pipelines, TypeScript versions other than 7.0.2 and 5.9.3, and a real pnpm pack install.

A patch changeset is included.

…larations

A consumer that exports a port it declares could not emit declarations:

  export class OrderRepository extends Port("OrderRepository")<Shape> {}

emits as `declare const OrderRepository_base: <the heritage expression's
type>`, and the emitter can only write that type using names the consumer can
reach. `PortClass`/`ManyPortClass` were not exported from the package index, so
it had none: it expanded the heritage expression down to `PortInstance`'s
`[ID]`/`[SERVICE]`/`[MANY]` keys — module-private `unique symbol`s — and
reported TS4020, "has or is using private name 'ID'". That is the pattern
`packages/di/README.md` teaches on its first page, so it affected essentially
every real consumer.

Exporting the two class *types* is the fix that costs least. The emitter now
stops at `PortClass<"OrderRepository">` (2,683 bytes of consumer declarations
across the reproduction, against 3,545 when only the instance types are
nameable and the construct signature has to be written out), and `port.ts` is
untouched.

The brand symbols stay unexported deliberately. Exporting them also fixes emit,
but a consumer who can name `ID`/`SERVICE` can hand-write
`{ [ID]: "Logger", [SERVICE]: Shape }` and pass it off as a `Logger` — measured,
it type-checks. Naming the class types grants no such thing: the brand keys stay
unreachable, so port identity stays nominal and a port instance stays
unforgeable. As an ambient `export declare const` they have no runtime binding
and rolldown rejects the index re-export outright (MISSING_EXPORT); making them
real `Symbol()` values would add runtime surface to phantom tokens that are
never constructed.

The three example packages carried `declaration: false` in their own tsconfigs
to dodge this. That is legitimate only because they are private, and it is what
kept the repo green while no consumer could build, so it goes with the fix.
`packages/di`'s own checks never emitted a consumer's declarations, so TS4020
could not be seen from inside the repo — the three example packages had turned
`declaration` off, and the library's own `tsc --noEmit` only ever compiles code
that can name `port.ts`'s brand symbols directly.

`examples/hexagonal-order-api/src/emit-guards.ts` is that missing consumer: a
file imported by nothing, which exists to be *compiled*. It names a plain port,
a `Port.many` set port, a port reaching through another port's `ServiceOf`, the
providers and module built on them, and the two factories whose return type is
a port class rather than an instance — the shapes that fail through different
brands, so a fix naming only one of the class types leaves the other broken.

`tsconfig.emit.json` turns `noEmit` back off (TS4020 is raised by the
declaration *emitter*, so a `--noEmit` pass cannot be the whole gate) and the
package's `typecheck` script runs it under both 7.0.2 and a stable-line 5.9.3,
then feeds the emitted `.d.ts` back through the compiler — a dangling reference
in the output is not an emit-time diagnostic and would otherwise ship.
`emit-guards.d.ts` is named explicitly in that last step because nothing imports
it, so it would go unchecked on `index.d.ts` alone.

The `@ts-expect-error` directives in the fixture are the other half: they assert
that `ID`/`SERVICE`/`MANY` are still unreachable and that two structurally
identical ports with different ids still do not unify. An unused directive there
is a failure, not noise — it is the signal that someone bought declaration emit
by widening the export surface far enough to forge a port.

Verified with teeth: reverting the index re-export puts eleven TS4020/TS4023
errors back through this fixture. `knip.jsonc` names it an entry, without which
knip reports it as an unused file.
Copilot AI lite review requested due to automatic review settings August 9, 2026 17:53

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-emission failure for downstream consumers that export Port(...) / Port.many(...)-derived classes, by making the relevant heritage-expression types nameable from @btravstack/di’s public index while keeping the nominal-brand unique symbols unexported.

Changes:

  • Re-export PortClass and ManyPortClass as types from packages/di/src/index.ts to unblock consumer .d.ts emit (avoids TS4020/TS4023).
  • Add a consumer-style declaration emit regression gate in examples/hexagonal-order-api (emit with repo TS and “consumer” TS, then re-typecheck the emitted .d.ts).
  • Remove declaration: false workarounds from example tsconfigs and add a typescript-consumer catalog alias for the second emit pass.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pnpm-workspace.yaml Adds typescript-consumer catalog alias to run a second emit pass with a stable consumer TS version.
pnpm-lock.yaml Locks the new typescript-consumer alias and TypeScript 5.9.3 package entry.
packages/di/src/index.ts Re-exports PortClass / ManyPortClass as types so consumer declaration emit can name the heritage type.
knip.jsonc Declares emit-guards.ts as an entry for the example workspace to prevent dead-code cleanup of the regression guard.
examples/request-scope/tsconfig.json Removes declaration: false workaround now that the library export surface supports declaration emit.
examples/plugin-registry/tsconfig.json Removes declaration: false workaround now that the library export surface supports declaration emit.
examples/hexagonal-order-api/tsconfig.json Removes declaration: false workaround and relies on the fixed @btravstack/di typings.
examples/hexagonal-order-api/tsconfig.emit.json Adds an emit-only tsconfig to actually run the declaration emitter for the regression gate.
examples/hexagonal-order-api/src/emit-guards.ts Adds compile-time regression assertions covering exported ports, Port.many, and brand non-exportability.
examples/hexagonal-order-api/package.json Extends typecheck to run emit + re-check emitted .d.ts under both TS versions.
.changeset/fresh-pears-smoke.md Adds a patch changeset documenting the consumer TS4020 fix and its rationale.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread knip.jsonc Outdated
@btravers
btravers merged commit 8769ac8 into main Aug 9, 2026
13 checks passed
@btravers
btravers deleted the fix/port-declaration-emit branch August 9, 2026 18:05
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