feat(di): export ConcretePortClass for ports built from data - #12
feat(di): export ConcretePortClass for ports built from data#12btravers wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
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>inport.tsto name “fixed-service” port class values without exporting the private brand symbols. - Re-exports
FixedPortClass(type-only) from the package index alongsidePortClass/ManyPortClass. - Extends the declaration-emit regression fixture (
emit-guards.ts) withdefineFixedPort, 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.
| * 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. |
There was a problem hiding this comment.
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.
| /** | ||
| * 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> = { |
There was a problem hiding this comment.
| 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> = { |
| * | ||
| * The brands themselves stay unexported, so this buys naming, not forgery. | ||
| */ | ||
| export type FixedPortClass<Id extends string, Service> = { |
There was a problem hiding this comment.
Would FinalPort be a better naming than FixedPort? Or ValuePort?
There was a problem hiding this comment.
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).
|
Closing unmerged — the need evaporated. This PR existed to support a design where 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);
Verified rather than assumed: config's full gate — build, typecheck (including declaration emit), 27 tests, lint, format — is green against the published Worth keeping from this branch, if the shape ever comes back: exporting #13 (unthrown 5.5.0 catalog) is unaffected and still worth merging. |
|
Reopening. Benoit's review of the config API settled on the one-value shape after all — That means a factory does apply 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 Nothing about the branch changed while it was closed: the type is |
|
Reopened. The config API settled on the one-value shape after all — That means a factory applies 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 The branch is unchanged from when it was closed: the type is One mechanical note: closing the PR deleted the branch, so I restored it from the local commits — same SHAs ( |
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>
e351fb4 to
7ab1f64
Compare
|
Rebased onto Worth recording why the rebase mattered beyond hygiene: btravstack/start#35 builds |
`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.
…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.
Found while building
@btravstack/config, whoseConfig(prefix)(shape)derives a port's service from a schema record.The gap
PortClass<Id>(exported by #1 for declaration emit) covers the case whereServiceis still open — supplied later by a heritage clause at the consumer's ownclass X extends Port("X")<Shape> {}. The emitter stops atPortClass<"Metrics">and everything works.A factory that applies
Serviceitself 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:Same class of bug #1 fixed, in the one shape that fix did not reach — and
emit-guards.tssays 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. ExportingPortInstancewas 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 —PortInstanceand the brand symbols stay unexported, so a port instance remains unforgeable and the@ts-expect-errorguards onID/SERVICE/MANYare untouched.defineFixedPortinexamples/hexagonal-order-api/src/emit-guards.tsis the regression fixture, sitting beside the existingdefinePort/defineSetPortguards it complements: without the export it isTS4023, 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