From ac9dc2c4fe5f5002b2a38769ba6d32f606d330d3 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 09:24:19 +0200 Subject: [PATCH 1/5] feat(di): export FixedPortClass for ports built from data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .changeset/fixed-port-class.md | 18 ++++++++++++ .../hexagonal-order-api/src/emit-guards.ts | 28 ++++++++++++++++++- packages/di/src/index.ts | 27 +++++++++++++++++- packages/di/src/port.ts | 21 ++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 .changeset/fixed-port-class.md diff --git a/.changeset/fixed-port-class.md b/.changeset/fixed-port-class.md new file mode 100644 index 0000000..ebdcd94 --- /dev/null +++ b/.changeset/fixed-port-class.md @@ -0,0 +1,18 @@ +--- +"@btravstack/di": minor +--- + +Export `FixedPortClass`, the type a factory annotates its return +with when it builds a port from *data* rather than from a type argument. + +`PortClass` covers the open case, where `Service` arrives later from a +heritage clause (`class X extends Port("X") {}`). A factory that applies +`Service` itself — deriving it from a schema record, say — returns a class whose +instance type is fully resolved, and the declaration emitter had no exported +name to stop at: it expanded to the module-private `[ID]`/`[SERVICE]` brands and +every such consumer failed with `TS4023`. The same class of bug the `PortClass` +export fixed, in the one shape that export did not reach. + +The brands stay unexported, so this names the class without making a port +instance forgeable. `defineFixedPort` in `examples/hexagonal-order-api`'s +`emit-guards.ts` is the regression fixture. diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts index ebf054f..e48a988 100644 --- a/examples/hexagonal-order-api/src/emit-guards.ts +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -50,7 +50,14 @@ * just the instance types nameable, the plain port emitted and the set * port still reported `private name 'MANY'`. */ -import { Module, Port, Provider, type AnyPort, type ServiceOf } from "@btravstack/di"; +import { + Module, + Port, + Provider, + type AnyPort, + type FixedPortClass, + type ServiceOf, +} from "@btravstack/di"; import { Ok, type AsyncResult } from "unthrown"; import { @@ -160,3 +167,22 @@ export const identity =

(port: P): P => port; /** Factories whose *return* type is the class type itself, not an instance. */ export const definePort = (id: Id) => Port(id); export const defineSetPort = (id: Id) => Port.many(id); + +/** + * A factory that fixes the service shape **inside itself** and returns the + * class — what a port built from data rather than from a type argument looks + * like (`@btravstack/config`'s `Config(prefix)(shape)`, which derives the + * service from a schema record). + * + * `definePort` above stops at `PortClass` because `Service` is still open + * there, applied later by the consumer's own heritage clause. Here it is + * 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. + */ +export const defineFixedPort = ( + id: Id, +): FixedPortClass => { + class Fixed extends Port(id)<{ readonly value: string }> {} + return Fixed; +}; diff --git a/packages/di/src/index.ts b/packages/di/src/index.ts index 06c67e9..5af48c3 100644 --- a/packages/di/src/index.ts +++ b/packages/di/src/index.ts @@ -35,7 +35,32 @@ export { Port } from "./port.js"; // the `[MANY]` intersection are never named here either — nothing in the emitted // output needs them once the class types are reachable, and `emit-guards.ts` in // `examples/hexagonal-order-api` is the fixture that keeps that true. -export type { AnyPort, ManyPortClass, PortClass, Scope, ServiceOf } from "./port.js"; +// +// `FixedPortClass` was added for one shape the sentence above did not cover: a +// factory that fixes `Service` *inside itself* and returns the class as a value +// — `Config(prefix)(shape)` in `@btravstack/config`, which builds a port from a +// schema record. `PortClass` is enough only while `Service` is still open, +// applied later by a heritage clause at the consumer's own `class X extends +// Port("X") {}`. Once the factory has applied it, the return type IS the +// instance type, the emitter has nothing to stop at, and every such consumer +// failed with `TS4023: 'X' has or is using name 'ID' from external module but +// cannot be named`. `FixedPortClass` is the name it stops at, and +// `defineFixedPort` in `emit-guards.ts` is that shape, kept compiling. +// `PortInstance` itself stays unexported: annotating the factory's return is +// enough, so the smaller widening is the one that ships. +// +// The safety argument is unchanged and is the one this file already makes: the +// brand *keys* stay unexported, so naming the instance type buys no forgery — +// a consumer still cannot write `{ [ID]: "Clock", [SERVICE]: Shape }`, and the +// `@ts-expect-error` directives in `emit-guards.ts` are what hold that line. +export type { + AnyPort, + FixedPortClass, + ManyPortClass, + PortClass, + Scope, + ServiceOf, +} from "./port.js"; export { Context } from "./context.js"; export { Provider } from "./provider.js"; export { Module } from "./module.js"; diff --git a/packages/di/src/port.ts b/packages/di/src/port.ts index 25901ef..703bb2d 100644 --- a/packages/di/src/port.ts +++ b/packages/di/src/port.ts @@ -37,6 +37,27 @@ export type PortClass = { * 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` covers the open case, where `Service` is still supplied by a + * heritage clause at the consumer's own `class X extends Port("X") {}`. + * 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 = { + new (): PortInstance; + readonly portId: Id; +}; + export type ManyPortClass = { new (): PortInstance & { readonly [MANY]: true }; readonly portId: Id; From dabc429d73e8c7cc0d49edac406088748f5fe36a Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 12:05:53 +0200 Subject: [PATCH 2/5] style: oxfmt the changeset Co-Authored-By: Claude Fable 5 --- .changeset/fixed-port-class.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fixed-port-class.md b/.changeset/fixed-port-class.md index ebdcd94..88b4b53 100644 --- a/.changeset/fixed-port-class.md +++ b/.changeset/fixed-port-class.md @@ -3,7 +3,7 @@ --- Export `FixedPortClass`, the type a factory annotates its return -with when it builds a port from *data* rather than from a type argument. +with when it builds a port from _data_ rather than from a type argument. `PortClass` covers the open case, where `Service` arrives later from a heritage clause (`class X extends Port("X") {}`). A factory that applies From 17c049e3f58a90146d374b39ee722b005963d4c1 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 12:08:19 +0200 Subject: [PATCH 3/5] docs: name the return annotation, not PortInstance, as the emitter's stop Co-Authored-By: Claude Fable 5 --- examples/hexagonal-order-api/src/emit-guards.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts index e48a988..a3f033a 100644 --- a/examples/hexagonal-order-api/src/emit-guards.ts +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -176,9 +176,12 @@ export const defineSetPort = (id: Id) => Port.many(id); * * `definePort` above stops at `PortClass` because `Service` is still open * there, applied later by the consumer's own heritage clause. Here it is - * 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. + * already applied, so there is no such name to stop at and the emitter expands + * to the private `[ID]`/`[SERVICE]` brands. The **return annotation** below is + * the stop: naming `FixedPortClass` is what keeps the emitter out + * of the brands. Exporting `PortInstance` was tried first and does not do it — + * it names the instance, not the class. Drop the annotation and this line is + * `TS4023`, not a style preference. */ export const defineFixedPort = ( id: Id, From 7ab1f64819922d8c535f9484b2e85c00f74b0af2 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 12:20:42 +0200 Subject: [PATCH 4/5] refactor: rename FixedPortClass to ConcretePortClass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../{fixed-port-class.md => concrete-port-class.md} | 4 ++-- examples/hexagonal-order-api/src/emit-guards.ts | 8 ++++---- packages/di/src/index.ts | 8 ++++---- packages/di/src/port.ts | 10 ++++++---- 4 files changed, 16 insertions(+), 14 deletions(-) rename .changeset/{fixed-port-class.md => concrete-port-class.md} (82%) diff --git a/.changeset/fixed-port-class.md b/.changeset/concrete-port-class.md similarity index 82% rename from .changeset/fixed-port-class.md rename to .changeset/concrete-port-class.md index 88b4b53..37d16ea 100644 --- a/.changeset/fixed-port-class.md +++ b/.changeset/concrete-port-class.md @@ -2,7 +2,7 @@ "@btravstack/di": minor --- -Export `FixedPortClass`, the type a factory annotates its return +Export `ConcretePortClass`, the type a factory annotates its return with when it builds a port from _data_ rather than from a type argument. `PortClass` covers the open case, where `Service` arrives later from a @@ -14,5 +14,5 @@ every such consumer failed with `TS4023`. The same class of bug the `PortClass` export fixed, in the one shape that export did not reach. The brands stay unexported, so this names the class without making a port -instance forgeable. `defineFixedPort` in `examples/hexagonal-order-api`'s +instance forgeable. `defineConcretePort` in `examples/hexagonal-order-api`'s `emit-guards.ts` is the regression fixture. diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts index a3f033a..ad7606a 100644 --- a/examples/hexagonal-order-api/src/emit-guards.ts +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -55,7 +55,7 @@ import { Port, Provider, type AnyPort, - type FixedPortClass, + type ConcretePortClass, type ServiceOf, } from "@btravstack/di"; import { Ok, type AsyncResult } from "unthrown"; @@ -178,14 +178,14 @@ export const defineSetPort = (id: Id) => Port.many(id); * there, applied later by the consumer's own heritage clause. Here it is * already applied, so there is no such name to stop at and the emitter expands * to the private `[ID]`/`[SERVICE]` brands. The **return annotation** below is - * the stop: naming `FixedPortClass` is what keeps the emitter out + * the stop: naming `ConcretePortClass` is what keeps the emitter out * of the brands. Exporting `PortInstance` was tried first and does not do it — * it names the instance, not the class. Drop the annotation and this line is * `TS4023`, not a style preference. */ -export const defineFixedPort = ( +export const defineConcretePort = ( id: Id, -): FixedPortClass => { +): ConcretePortClass => { class Fixed extends Port(id)<{ readonly value: string }> {} return Fixed; }; diff --git a/packages/di/src/index.ts b/packages/di/src/index.ts index 5af48c3..0c8d731 100644 --- a/packages/di/src/index.ts +++ b/packages/di/src/index.ts @@ -36,7 +36,7 @@ export { Port } from "./port.js"; // output needs them once the class types are reachable, and `emit-guards.ts` in // `examples/hexagonal-order-api` is the fixture that keeps that true. // -// `FixedPortClass` was added for one shape the sentence above did not cover: a +// `ConcretePortClass` was added for one shape the sentence above did not cover: a // factory that fixes `Service` *inside itself* and returns the class as a value // — `Config(prefix)(shape)` in `@btravstack/config`, which builds a port from a // schema record. `PortClass` is enough only while `Service` is still open, @@ -44,8 +44,8 @@ export { Port } from "./port.js"; // Port("X") {}`. Once the factory has applied it, the return type IS the // instance type, the emitter has nothing to stop at, and every such consumer // failed with `TS4023: 'X' has or is using name 'ID' from external module but -// cannot be named`. `FixedPortClass` is the name it stops at, and -// `defineFixedPort` in `emit-guards.ts` is that shape, kept compiling. +// cannot be named`. `ConcretePortClass` is the name it stops at, and +// `defineConcretePort` in `emit-guards.ts` is that shape, kept compiling. // `PortInstance` itself stays unexported: annotating the factory's return is // enough, so the smaller widening is the one that ships. // @@ -55,7 +55,7 @@ export { Port } from "./port.js"; // `@ts-expect-error` directives in `emit-guards.ts` are what hold that line. export type { AnyPort, - FixedPortClass, + ConcretePortClass, ManyPortClass, PortClass, Scope, diff --git a/packages/di/src/port.ts b/packages/di/src/port.ts index 703bb2d..28bd100 100644 --- a/packages/di/src/port.ts +++ b/packages/di/src/port.ts @@ -38,8 +38,10 @@ export type PortClass = { * 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 + * A port class whose `Service` is already applied — *concrete*, in the sense + * the note on `PortClass` below already uses ("has a concrete constructor once + * `Shape` is fixed"). This is 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` covers the open case, where `Service` is still supplied by a @@ -48,12 +50,12 @@ export type PortClass = { * 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 + * alias is the stop the emitter needs — see `defineConcretePort` in * `examples/hexagonal-order-api/src/emit-guards.ts`. * * The brands themselves stay unexported, so this buys naming, not forgery. */ -export type FixedPortClass = { +export type ConcretePortClass = { new (): PortInstance; readonly portId: Id; }; From aa7c69241ec935a9ecdb4a35dc174be6e2c134c3 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Fri, 14 Aug 2026 17:57:49 +0200 Subject: [PATCH 5/5] feat(di): export PortInstance for modules that export a data-built port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .changeset/concrete-port-class.md | 24 ++++++++++++---- .../hexagonal-order-api/src/emit-guards.ts | 28 +++++++++++++++++++ packages/di/src/index.ts | 16 +++++++++-- packages/di/src/port.ts | 4 +++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/.changeset/concrete-port-class.md b/.changeset/concrete-port-class.md index 37d16ea..dc58ed8 100644 --- a/.changeset/concrete-port-class.md +++ b/.changeset/concrete-port-class.md @@ -2,8 +2,9 @@ "@btravstack/di": minor --- -Export `ConcretePortClass`, the type a factory annotates its return -with when it builds a port from _data_ rather than from a type argument. +Export `ConcretePortClass` and `PortInstance`, the two +names declaration emit needs when a port is built from _data_ rather than from a +type argument. `PortClass` covers the open case, where `Service` arrives later from a heritage clause (`class X extends Port("X") {}`). A factory that applies @@ -11,8 +12,19 @@ heritage clause (`class X extends Port("X") {}`). A factory that applies instance type is fully resolved, and the declaration emitter had no exported name to stop at: it expanded to the module-private `[ID]`/`[SERVICE]` brands and every such consumer failed with `TS4023`. The same class of bug the `PortClass` -export fixed, in the one shape that export did not reach. +export fixed, in the shapes that export did not reach. -The brands stay unexported, so this names the class without making a port -instance forgeable. `defineConcretePort` in `examples/hexagonal-order-api`'s -`emit-guards.ts` is the regression fixture. +There are two such shapes, and they need different names. A factory _returning_ +the port is fixed by annotating its return with `ConcretePortClass` — naming the +class is the stop. A module that **exports** the port inverts it: `Module`'s +first type argument is the union of exported port _instances_, so the emitter +needs `PortInstance` exactly where the class name is no help. Exporting only one +of the two leaves the other broken, which is why `PortInstance` was tried alone +first and rejected as insufficient. + +The brands stay unexported, so both names buy naming without making a port +instance forgeable — the `@ts-expect-error` directives in `emit-guards.ts` are +the assertion that `ID`/`SERVICE`/`MANY` are still out of reach. +`defineConcretePort` and `ModuleExportingDataBuiltPort` in +`examples/hexagonal-order-api`'s `emit-guards.ts` are the regression fixtures, +one per shape. diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts index ad7606a..3c03c6c 100644 --- a/examples/hexagonal-order-api/src/emit-guards.ts +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -189,3 +189,31 @@ export const defineConcretePort = ( class Fixed extends Port(id)<{ readonly value: string }> {} return Fixed; }; + +/** + * The second half of the same bug, and the one the return annotation above does + * NOT reach. + * + * `defineConcretePort` covers a factory *returning* a data-built port: there, + * naming the class is the stop, and exporting `PortInstance` would not have + * helped because the emitter needs the class, not the instance. Put that port + * in a module's `exports:` and the shape inverts — `Module`'s first type + * argument is the union of exported port *instances*, so the emitter now needs + * exactly the name the other case did not: `PortInstance`. With it unexported + * this line was `TS4023: has or is using name 'ID' ... but cannot be named`. + * + * Both names are load bearing, for opposite reasons, which is why neither one + * alone closed the hole. Measured in `@btravstack/config`: annotating the + * factory's return fixed every consumer that *declared* a config and none that + * *exported* one from its composition root. + * + * The forgery directives above are what keep this from being bought too + * cheaply — naming `PortInstance` is safe only while `ID`/`SERVICE` stay + * unreachable, and those `@ts-expect-error`s are the assertion that they are. + */ +const DataBuiltPort = defineConcretePort("DataBuiltPort"); + +export const ModuleExportingDataBuiltPort = Module("ModuleExportingDataBuiltPort")({ + provides: [Provider(DataBuiltPort)({ value: { value: "" } })], + exports: [DataBuiltPort], +}); diff --git a/packages/di/src/index.ts b/packages/di/src/index.ts index 0c8d731..41d9c50 100644 --- a/packages/di/src/index.ts +++ b/packages/di/src/index.ts @@ -46,8 +46,19 @@ export { Port } from "./port.js"; // failed with `TS4023: 'X' has or is using name 'ID' from external module but // cannot be named`. `ConcretePortClass` is the name it stops at, and // `defineConcretePort` in `emit-guards.ts` is that shape, kept compiling. -// `PortInstance` itself stays unexported: annotating the factory's return is -// enough, so the smaller widening is the one that ships. +// +// `PortInstance` is the *other* half of that same bug, and the reason two names +// ship rather than one. Annotating the factory's return covers a consumer that +// DECLARES a data-built port; put that port in a module's `exports:` and the +// shape inverts. `Module`'s first type argument is the union of exported port +// *instances*, so the emitter needs the instance name precisely where the class +// name is no help — and the class name was no help, which is why exporting +// `PortInstance` alone was tried first and rejected. Neither closes the hole +// alone. 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 example deleted from its own packages, for the same reason. +// `ModuleExportingDataBuiltPort` in `emit-guards.ts` is that shape. // // The safety argument is unchanged and is the one this file already makes: the // brand *keys* stay unexported, so naming the instance type buys no forgery — @@ -58,6 +69,7 @@ export type { ConcretePortClass, ManyPortClass, PortClass, + PortInstance, Scope, ServiceOf, } from "./port.js"; diff --git a/packages/di/src/port.ts b/packages/di/src/port.ts index 28bd100..1f5ecf2 100644 --- a/packages/di/src/port.ts +++ b/packages/di/src/port.ts @@ -54,6 +54,10 @@ export type PortClass = { * `examples/hexagonal-order-api/src/emit-guards.ts`. * * The brands themselves stay unexported, so this buys naming, not forgery. + * + * This covers a factory *returning* such a port. A module that **exports** one + * inverts the shape and needs {@link PortInstance} instead — neither name + * closes both cases; see the note above the export list in `index.ts`. */ export type ConcretePortClass = { new (): PortInstance;