Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/concrete-port-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@btravstack/di": minor
---

Export `ConcretePortClass<Id, Service>` and `PortInstance<Id, Service>`, the two
names declaration emit needs when a port is built from _data_ rather than from a
type argument.

`PortClass<Id>` covers the open case, where `Service` arrives later from a
heritage clause (`class X extends Port("X")<Shape> {}`). 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.
59 changes: 58 additions & 1 deletion examples/hexagonal-order-api/src/emit-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -160,3 +167,53 @@ export const identity = <P extends AnyPort>(port: P): P => port;
/** Factories whose *return* type is the class type itself, not an instance. */
export const definePort = <const Id extends string>(id: Id) => Port(id);
export const defineSetPort = <const Id extends string>(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<Id>` 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<Id, Service>` 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 = <const Id extends string>(
id: Id,
): ConcretePortClass<Id, { readonly value: string }> => {
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],
});
39 changes: 38 additions & 1 deletion packages/di/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Id>` is enough only while `Service` is still open,
// applied later by a heritage clause at the consumer's own `class X extends
// Port("X")<Shape> {}`. 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<Id, Service>` 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";
Expand Down
27 changes: 27 additions & 0 deletions packages/di/src/port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ export type PortClass<Id extends string> = {
* 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<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 `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<Id extends string, Service> = {
new (): PortInstance<Id, Service>;
readonly portId: Id;
};

export type ManyPortClass<Id extends string> = {
new <Member>(): PortInstance<Id, readonly Member[]> & { readonly [MANY]: true };
readonly portId: Id;
Expand Down
Loading