fix(di): let consumers emit declarations for their own ports - #1
Merged
Conversation
…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.
There was a problem hiding this comment.
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
PortClassandManyPortClassas types frompackages/di/src/index.tsto unblock consumer.d.tsemit (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: falseworkarounds from example tsconfigs and add atypescript-consumercatalog 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.
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.
Exporting a port you declare — the pattern
packages/di/README.mdteaches — could not emit declarations. Any consumer withdeclaration: true, which is the shared tsconfig default, got:ID,SERVICEandMANYare module-privateunique symbols carried byPortInstance, so TypeScript could not write the consumer's.d.ts.The fix
One line in
packages/di/src/index.ts: re-exportPortClassandManyPortClassas types. The emitter can then name the heritage expression instead of expanding it to the brands.port.tsis untouched and the symbols stay unexported: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.tsunchanged 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:
MISSING_EXPORT, build failsSymbol()valuesManyPortInstanceRegression guard
examples/hexagonal-order-api/src/emit-guards.tsplustsconfig.emit.json, mirroring the arrangement inbtravstack/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'stypecheckscript sopnpm typecheckruns it. Verified with teeth: reverting the index re-export puts 11 TS4020/TS4023 errors back.knip.jsoncnames it an entry — also verified load-bearing.The
declaration: falseworkaround is removed from all three example tsconfigs; it was hiding this.Verification
lint,oxfmt --check,typecheck5/5,test5/5 (48 tests),test:types3/3,build,knip— all clean, forced past turbo's cache. The reproduction was re-run against the rebuiltdist/by hand: emit exit 0 for a plain port, aPort.manyset port, and a port whose shape references another port'sServiceOf.Not verified: consumer-side bundler
.d.tspipelines, TypeScript versions other than 7.0.2 and 5.9.3, and a realpnpm packinstall.A patch changeset is included.