Skip to content

feat(di): export ConcretePortClass for ports built from data - #12

Open
btravers wants to merge 5 commits into
mainfrom
fix/export-port-instance
Open

feat(di): export ConcretePortClass for ports built from data#12
btravers wants to merge 5 commits into
mainfrom
fix/export-port-instance

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Found while building @btravstack/config, whose Config(prefix)(shape) derives a port's service from a schema record.

The gap

PortClass<Id> (exported by #1 for declaration emit) covers the case where Service is still open — supplied later by a heritage clause at the consumer's own class X extends Port("X")<Shape> {}. The emitter stops at PortClass<"Metrics"> and everything works.

A factory that applies Service itself returns a class whose instance type is already resolved. There is no exported name to stop at, so the emitter expands to the module-private [ID]/[SERVICE] brands:

error TS4023: Exported variable 'defineFixedPort' has or is using name 'ID'
from external module "@btravstack/di" but cannot be named.

Same class of bug #1 fixed, in the one shape that fix did not reach — and emit-guards.ts says as much in its own words ("nothing in the emitted output needs them once the class types are reachable"), which held until a port could be built from data.

The fix

FixedPortClass<Id, Service> — the name such a factory annotates its return with. Exporting PortInstance was tried first and is not enough: it names the instance, not the class, so the emitter still expands. Annotating the return is what stops it, which also means the smaller widening is the one that ships — PortInstance and the brand symbols stay unexported, so a port instance remains unforgeable and the @ts-expect-error guards on ID/SERVICE/MANY are untouched.

defineFixedPort in examples/hexagonal-order-api/src/emit-guards.ts is the regression fixture, sitting beside the existing definePort/defineSetPort guards it complements: without the export it is TS4023, which I verified by reproducing the failure before adding the type.

Gate green: 10/10 turbo tasks (build, typecheck incl. the emit re-check, test), lint and format clean.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 14, 2026 07:24

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

Adds a new exported type alias to support TypeScript declaration emit for ports whose service type is fixed inside a factory (i.e., factories that return a fully-resolved port class value). This closes a remaining TS4023 failure mode for downstream consumers emitting .d.ts.

Changes:

  • Introduces FixedPortClass<Id, Service> in port.ts to name “fixed-service” port class values without exporting the private brand symbols.
  • Re-exports FixedPortClass (type-only) from the package index alongside PortClass/ManyPortClass.
  • Extends the declaration-emit regression fixture (emit-guards.ts) with defineFixedPort, and adds a changeset for the new exported type.

Reviewed changes

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

File Description
packages/di/src/port.ts Adds the FixedPortClass<Id, Service> type alias for naming fixed-service port classes.
packages/di/src/index.ts Re-exports FixedPortClass as a type to make it usable by consumers for .d.ts emit.
examples/hexagonal-order-api/src/emit-guards.ts Adds a new emit-guard shape (defineFixedPort) using FixedPortClass.
.changeset/fixed-port-class.md Documents the new export and why it’s needed (TS4023 scenario).

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

Comment on lines +179 to +181
* already applied, so the return type IS the instance type and the emitter has
* nothing to stop at short of `PortInstance` — which is why that type is
* exported. Without it this line is `TS4023`, not a style preference.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fee47a6. The comment claimed the emitter needed PortInstance exported; that was left over from the first attempt, which did export it and did NOT work — naming the instance does not stop the emitter, annotating the factory's return does. The comment now says that, and says the annotation is the stop, so a reader who deletes it knows what breaks.

Comment thread packages/di/src/port.ts
Comment on lines 20 to 61
/**
* A set port: several providers may target it, and `Context.get` yields every
* contribution rather than one service. `Port.many("Id")<Member>` fixes the
* *member* shape via the same generic-heritage-instantiation trick
* `PortClass` uses (`class Handlers extends Port.many("Id")<Member> {}`), but
* the port's own `Service` — what actually lands in a `Context` and what
* `Context.get` returns — is `readonly Member[]`, not `Member`. The `[MANY]`
* brand on the instance type exists purely at the type level (no
* `ManyPortClass` is ever constructed at runtime; ports are phantom tokens,
* same as `PortClass`) so a member's *own* shape can be recovered from a
* concrete set-port class via `MemberOf` below, the same way `ServiceOf`
* recovers an ordinary port's shape from `PortInstance`.
*
* The `many: true` *static* field is the actual runtime discriminant —
* `build.ts`'s `plan`/`constructLevel` read `port.many` off the concrete
* class object at runtime (inherited from whatever `Port.many` returns, the
* same way a concrete port's `portId` is inherited), since the `[MANY]`
* symbol lives only in the (never-instantiated) instance type and cannot be
* read back at runtime.
*/
/**
* A port class whose `Service` is already applied — what a factory returns when
* it builds a port from data rather than from a type argument
* (`Config(prefix)(shape)` deriving a service from a schema record).
*
* `PortClass<Id>` covers the open case, where `Service` is still supplied by a
* heritage clause at the consumer's own `class X extends Port("X")<Shape> {}`.
* Once a factory has applied it, the return type is a class whose instance is a
* fully-resolved `PortInstance`, and the declaration emitter has no exported
* name to stop at: it expands to the `[ID]`/`[SERVICE]` brands and every
* consumer fails with `TS4023`. Annotating such a factory's return with this
* alias is the stop the emitter needs — see `defineFixedPort` in
* `examples/hexagonal-order-api/src/emit-guards.ts`.
*
* The brands themselves stay unexported, so this buys naming, not forgery.
*/
export type FixedPortClass<Id extends string, Service> = {
new (): PortInstance<Id, Service>;
readonly portId: Id;
};

export type ManyPortClass<Id extends string> = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export type ManyPortClass<Id extends string> = {
/**
* A port class whose `Service` is already applied what a factory returns when
* it builds a port from data rather than from a type argument
* (`Config(prefix)(shape)` deriving a service from a schema record).
*
* `PortClass<Id>` covers the open case, where `Service` is still supplied by a
* heritage clause at the consumer's own `class X extends Port("X")<Shape> {}`.
* Once a factory has applied it, the return type is a class whose instance is a
* fully-resolved `PortInstance`, and the declaration emitter has no exported
* name to stop at: it expands to the `[ID]`/`[SERVICE]` brands and every
* consumer fails with `TS4023`. Annotating such a factory's return with this
* alias is the stop the emitter needs see `defineFixedPort` in
* `examples/hexagonal-order-api/src/emit-guards.ts`.
*
* The brands themselves stay unexported, so this buys naming, not forgery.
*/
export type FixedPortClass<Id extends string, Service> = {
new (): PortInstance<Id, Service>;
readonly portId: Id;
};
/**
* A set port: several providers may target it, and `Context.get` yields every
* contribution rather than one service. `Port.many("Id")<Member>` fixes the
* *member* shape via the same generic-heritage-instantiation trick
* `PortClass` uses (`class Handlers extends Port.many("Id")<Member> {}`), but
* the port's own `Service` — what actually lands in a `Context` and what
* `Context.get` returns is `readonly Member[]`, not `Member`. The `[MANY]`
* brand on the instance type exists purely at the type level (no
* `ManyPortClass` is ever constructed at runtime; ports are phantom tokens,
* same as `PortClass`) so a member's *own* shape can be recovered from a
* concrete set-port class via `MemberOf` below, the same way `ServiceOf`
* recovers an ordinary port's shape from `PortInstance`.
*
* The `many: true` *static* field is the actual runtime discriminant
* `build.ts`'s `plan`/`constructLevel` read `port.many` off the concrete
* class object at runtime (inherited from whatever `Port.many` returns, the
* same way a concrete port's `portId` is inherited), since the `[MANY]`
* symbol lives only in the (never-instantiated) instance type and cannot be
* read back at runtime.
*/
export type ManyPortClass<Id extends string> = {

Comment thread packages/di/src/port.ts Outdated
*
* The brands themselves stay unexported, so this buys naming, not forgery.
*/
export type FixedPortClass<Id extends string, Service> = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would FinalPort be a better naming than FixedPort? Or ValuePort?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConcretePortClass — done in e351fb4, across the type, the guard (defineConcretePort), the changeset and the prose.

Neither of your two quite fit, but they pushed me to the word the repo had already chosen. Final carries the Java sealed-class connotation — it suggests the class cannot be extended, when what actually changed is that the Service type argument is bound. Value captures the real axis (built from a value rather than a type argument) but reads cold as though the port's service is a value, or as though it carries something different from other ports.

The deciding evidence was already in port.ts: the existing note on PortClass says a port class "has a concrete constructor once Shape is fixed by the heritage clause". So the state has a name here, and the type had a different one. Now the pair reads as the distinction it is — generic PortClass<Id>, concrete ConcretePortClass<Id, Service> — and the paragraph explaining it uses the same word as the thing it explains.

@btravstack/config follows in its own commit (fccfd45).

@btravers

Copy link
Copy Markdown
Contributor Author

Closing unmerged — the need evaporated.

This PR existed to support a design where @btravstack/config's Config(prefix)(shape) returned a value that was simultaneously a di Port and a di Module. Because that factory applied Service itself, its return type was a service-applied port class, the declaration emitter had no exported name to stop at, and consumers hit TS4023 — hence ConcretePortClass.

Benoit's review of the config design took that apart at the root: a concrete port is not a port any more; a port aims to be adapted. Welding the port to its environment-parsing provider meant a test could not hand it a literal and a future file or secret-manager source could not be a different adapter for the same port.

config is reworked so it declares no port at all — it implements one the starter declares normally:

export class AmqpConfig extends Port("AmqpConfig")<ValueOf<typeof shape>> {}
export const AmqpConfigFromEnv = Config(AmqpConfig, "AMQP")(shape);

Config(...) now returns a Module<InstanceType<P>, never, ConfigSource>. Every name in that is printable, so no port class is ever emitted from a factory and the TS4023 this PR fixed cannot occur.

Verified rather than assumed: config's full gate — build, typecheck (including declaration emit), 27 tests, lint, format — is green against the published @btravstack/di@0.1.0, resolved from the registry with no workspace override. So di needs no change for config to ship.

Worth keeping from this branch, if the shape ever comes back: exporting PortInstance alone does not fix it — naming the instance does not stop the emitter; annotating the factory's return does. The defineConcretePort guard in emit-guards.ts is the fixture that proved it.

#13 (unthrown 5.5.0 catalog) is unaffected and still worth merging.

@btravers btravers closed this Aug 14, 2026
@btravers
btravers deleted the fix/export-port-instance branch August 14, 2026 12:55
@btravers

Copy link
Copy Markdown
Contributor Author

Reopening. Benoit's review of the config API settled on the one-value shape after all — Config("AmqpConfig")({ … }) returning a single value that is both the di module and the port token, so imports: [amqpConfig] and ctx.get(amqpConfig) name the same thing.

That means a factory does apply Service itself again, its return type is a port class the declaration emitter cannot name, and TS4023 is back — which is precisely what this PR fixes. The reasoning I closed it with ("no factory returns a port class any more") no longer holds.

Also worth recording, since it corrects the argument that led to closing it: I had claimed the one-value shape made a port un-adaptable. That was wrong. The value is a token, and its module statics are only consulted when it appears in imports: — so a test can always write provides: [Provider(amqpConfig)({ value: … })] and simply not import it. Adaptability was never the trade-off; declaration emit was, and this PR is the fix for it.

Nothing about the branch changed while it was closed: the type is ConcretePortClass<Id, Service> (renamed per your review), defineConcretePort in emit-guards.ts is the regression fixture, and the brands stay unexported so naming the class buys no forgery.

@btravers
btravers restored the fix/export-port-instance branch August 14, 2026 14:41
@btravers btravers reopened this Aug 14, 2026
@btravers btravers changed the title feat(di): export FixedPortClass for ports built from data feat(di): export ConcretePortClass for ports built from data Aug 14, 2026
@btravers

Copy link
Copy Markdown
Contributor Author

Reopened. The config API settled on the one-value shape after all — Config("AmqpConfig")({ … }) returning a single value that is both the di module and the port token, so imports: [amqpConfig] and ctx.get(amqpConfig) name the same thing.

That means a factory applies Service itself again, its return type is a port class the declaration emitter cannot name, and TS4023 is back — exactly what this PR fixes. The reason I closed it ("no factory returns a port class any more") stopped being true.

Worth recording, because it corrects the argument that led to closing it: I had claimed the one-value shape made a port un-adaptable. That was wrong. The value is a token, and its module statics are only consulted when it appears in imports: — so a test can always write provides: [Provider(amqpConfig)({ value: … })] and simply not import it. Adaptability was never the trade-off; declaration emit was, and this is the fix for it.

The branch is unchanged from when it was closed: the type is ConcretePortClass<Id, Service> (renamed per your review), defineConcretePort in emit-guards.ts is the regression fixture that fails with TS4023 without the export, and the brand symbols stay unexported so naming the class buys no forgery. Title updated to match the rename.

One mechanical note: closing the PR deleted the branch, so I restored it from the local commits — same SHAs (e351fb4 at the head).

Benoit Travers and others added 4 commits August 14, 2026 17:04
A factory that applies Service itself returns a class the declaration
emitter cannot name — it expands to the private ID/SERVICE brands and
fails with TS4023. PortClass only covers the open case, where Service
arrives from a heritage clause at the consumer.

The brands stay unexported: this names the class, it does not make an
instance forgeable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…stop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
di's own note on PortClass already calls this state concrete — "has a
concrete constructor once Shape is fixed" — so the type name and the
paragraph explaining it now use one word. The pair reads as the
distinction it is: generic PortClass, concrete ConcretePortClass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@btravers
btravers force-pushed the fix/export-port-instance branch from e351fb4 to 7ab1f64 Compare August 14, 2026 15:05
@btravers

Copy link
Copy Markdown
Contributor Author

Rebased onto main (was based on a commit predating #13's catalog bump to unthrown 5.5.0). Build, typecheck and the 40 tests are green on 5.5.0; ConcretePortClass is unchanged.

Worth recording why the rebase mattered beyond hygiene: btravstack/start#35 builds @btravstack/config against this branch through a link: override, and link: is a raw filesystem symlink rather than a workspace package — so that dist resolved its bare unthrown import from this checkout's node_modules. Two physically distinct installs of the same version are structurally identical but nominally distinct to tsc, which broke every recursive Result/AsyncResult generic across the boundary. Aligning the catalogs removed the need for a second override downstream.

`ConcretePortClass` fixed a factory *returning* such a port. A module that
exports one inverts the shape: `Module`'s first type argument is the union of
exported port instances, so the emitter needs the instance name exactly where
the class name is no help, and consumers hit TS4023 again.

Measured in @btravstack/config: the return annotation fixed every consumer that
declared a config and none that exported one from a composition root — which had
been papering over it with `declaration: false`, the same override this repo's
own examples deleted, for the same reason.

`ModuleExportingDataBuiltPort` in emit-guards.ts is the fixture; it reports
TS4023 on ID and SERVICE with the export removed. The brands stay unexported, so
the forgery directives still hold.
btravers pushed a commit to btravstack/start that referenced this pull request Aug 14, 2026
…rtInstance

The three deployables had turned off `declaration`/`declarationMap` because a
composition root exporting a config emitted `Module<InstanceType<typeof cfg>>`,
which reduced to di's unexported `PortInstance` and raised TS4023.

di exports it now (btravstack/di#12), so the override is unnecessary — and it
was the wrong shape of fix regardless: these packages are `noEmit`, so it
silenced the check without saving any output. di's own emit-guards.ts exists
because its examples once did exactly this, leaving the repo green while no
consumer could build.
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