diff --git a/.changeset/concrete-port-class.md b/.changeset/concrete-port-class.md new file mode 100644 index 0000000..dc58ed8 --- /dev/null +++ b/.changeset/concrete-port-class.md @@ -0,0 +1,30 @@ +--- +"@btravstack/di": minor +--- + +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 +`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 shapes that export did not reach. + +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 ebf054f..3c03c6c 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 ConcretePortClass, + type ServiceOf, +} from "@btravstack/di"; import { Ok, type AsyncResult } from "unthrown"; import { @@ -160,3 +167,53 @@ 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 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 `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 defineConcretePort = ( + id: Id, +): ConcretePortClass => { + 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 06c67e9..41d9c50 100644 --- a/packages/di/src/index.ts +++ b/packages/di/src/index.ts @@ -35,7 +35,44 @@ 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"; +// +// `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, +// 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`. `ConcretePortClass` is the name it stops at, and +// `defineConcretePort` in `emit-guards.ts` is that shape, kept compiling. +// +// `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 — +// 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, + ConcretePortClass, + ManyPortClass, + PortClass, + PortInstance, + 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..1f5ecf2 100644 --- a/packages/di/src/port.ts +++ b/packages/di/src/port.ts @@ -37,6 +37,33 @@ 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 — *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 + * 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 `defineConcretePort` in + * `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; + readonly portId: Id; +}; + export type ManyPortClass = { new (): PortInstance & { readonly [MANY]: true }; readonly portId: Id;