diff --git a/.changeset/fresh-pears-smoke.md b/.changeset/fresh-pears-smoke.md new file mode 100644 index 0000000..4d9ac49 --- /dev/null +++ b/.changeset/fresh-pears-smoke.md @@ -0,0 +1,11 @@ +--- +"@btravstack/di": patch +--- + +Fix `TS4020` in consumers that export a port. `export class OrderRepository extends +Port("OrderRepository") {}` — the pattern the README teaches — could not emit +declarations: the emitter had no name for the heritage expression's type, so it expanded +down to `PortInstance`'s module-private `unique symbol` brands and reported "has or is +using private name 'ID'". `PortClass` and `ManyPortClass` are now exported as types, which +gives the emitter a name to stop at. The brand symbols themselves stay unexported, so port +identity remains nominal and a port instance remains unforgeable. diff --git a/examples/hexagonal-order-api/package.json b/examples/hexagonal-order-api/package.json index 05b67e1..30faec0 100644 --- a/examples/hexagonal-order-api/package.json +++ b/examples/hexagonal-order-api/package.json @@ -12,7 +12,7 @@ "scripts": { "test": "vitest run", "test:types": "tsc --noEmit -p tsconfig.test-d.json", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test-d.json && tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc -p tsconfig.emit.json && node ./node_modules/typescript-consumer/bin/tsc --noEmit --strict --module nodenext --moduleResolution nodenext --target es2022 node_modules/.emit-check/index.d.ts node_modules/.emit-check/emit-guards.d.ts node_modules/.emit-check/index.spec.d.ts" }, "dependencies": { "@btravstack/di": "workspace:*", @@ -23,6 +23,7 @@ "@types/node": "catalog:", "@unthrown/vitest": "catalog:", "typescript": "catalog:", + "typescript-consumer": "catalog:", "vitest": "catalog:" } } diff --git a/examples/hexagonal-order-api/src/emit-guards.ts b/examples/hexagonal-order-api/src/emit-guards.ts new file mode 100644 index 0000000..ebf054f --- /dev/null +++ b/examples/hexagonal-order-api/src/emit-guards.ts @@ -0,0 +1,162 @@ +/** + * NOT example code. Do not copy anything out of this file. + * + * It is a compile-time test that happens to live beside an example, because + * what it tests *is* what an example is: a downstream package that uses + * `@btravstack/di` and **emits its own declarations**. Everything below is a + * consumer-side shape that `tsconfig.emit.json` has to be able to write into a + * `.d.ts` — the one thing `tsc --noEmit` on the example proper does not fully + * exercise, and the thing that was broken. + * + * What went wrong when nothing checked this: `PortInstance`'s brand keys are + * two module-private `unique symbol`s (`ID`/`SERVICE`, plus `MANY` for a set + * port), and neither `PortClass` nor `ManyPortClass` was exported from the + * package index. With no name to reach for, the emitter expanded a port class's + * heritage expression down to those symbols, and **every consumer that exported + * a port** — the pattern `packages/di/README.md` teaches on its first page — + * failed with `TS4020: 'extends' clause of exported class 'X' has or is using + * private name 'ID'`. The three example packages had been papering over it with + * `declaration: false` in their own tsconfigs, which is why the repo was green + * while no consumer could build. Those overrides are gone; this file is what + * replaced them. + * + * Three rules that are easy to destroy by tidying: + * + * 1. **An unused `@ts-expect-error` here is a failure, not noise.** The fix + * for `TS4020` is to make more of the port machinery nameable, and the + * cheap version of that — exporting `ID`/`SERVICE`/`MANY` themselves — + * makes emit work while quietly destroying the thing the brands exist for: + * with the symbols in hand a consumer hand-writes `{ [ID]: "Pool", + * [SERVICE]: Shape }` and passes it off as a `Pool`. Measured: it + * type-checks. The directives below are the assertion that the symbols are + * still out of reach, so a directive going unused is the signal that + * someone widened the export surface too far. + * + * 2. **The gate compiles this file, it does not merely check it.** Emit-time + * diagnostics like `TS4020` are raised by the *declaration emitter*, so + * `noEmit` has to be off for the pass to mean anything, and the emitted + * output is then fed back through the compiler — a dangling reference in + * the output is not an emit-time diagnostic and would otherwise ship. The + * re-check names `emit-guards.d.ts` explicitly rather than `index.d.ts` + * alone: a file is checked only if it is named or reached by an import from + * something named, and **nothing imports this one**. Never add + * `--skipLibCheck` to that step; it turns off `.d.ts` checking entirely and + * the run exits 0 on broken output. + * + * 3. **Both the plain port and the `Port.many` port are load bearing.** They + * fail through different brands — `ID`/`SERVICE` against `PortClass`, + * and additionally `MANY` against `ManyPortClass` — so a fix that names + * only one of the two class types leaves the other broken. Measured: with + * 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 { Ok, type AsyncResult } from "unthrown"; + +import { + InMemoryPersistenceModule, + OrderRepository, + makeAppModule, + type GetOrder, + type Order, +} from "./index.js"; + +/* ── The brands stay out of reach ────────────────────────────────────────── + Nominal identity is the whole point of the symbols; declaration emit must + not have been bought with it. */ + +class Clock extends Port("Clock")<{ readonly now: () => string }> {} +class Stopwatch extends Port("Stopwatch")<{ readonly now: () => string }> {} + +declare const structurallyIdentical: Stopwatch; +// @ts-expect-error two ports with identical service shapes but different ids do not unify +const unified: Clock = structurallyIdentical; +void unified; + +declare const handWritten: { + readonly id: "Clock"; + readonly service: { readonly now: () => string }; +}; +// @ts-expect-error a port instance cannot be forged: its brand keys are module-private symbols +const forged: Clock = handWritten; +void forged; + +// @ts-expect-error nor by supplying the service shape on its own +const forgedFromService: Clock = { now: () => "" }; +void forgedFromService; + +// The brand keys themselves have no name a consumer can reach. Each of these +// resolves only if the package starts exporting the symbol, at which point the +// forgery above becomes writable — which is why the directives, not a comment, +// are what holds the export surface where it is. +// @ts-expect-error `@btravstack/di` exports no `ID` +declare const idBrand: typeof import("@btravstack/di").ID; +// @ts-expect-error `@btravstack/di` exports no `SERVICE` +declare const serviceBrand: typeof import("@btravstack/di").SERVICE; +// @ts-expect-error `@btravstack/di` exports no `MANY` +declare const manyBrand: typeof import("@btravstack/di").MANY; +void idBrand; +void serviceBrand; +void manyBrand; + +/* ── Exported ports: the shapes that tripped TS4020 ───────────────────────── */ + +/** A plain port. Fails on `ID`/`SERVICE` when `PortClass` is not nameable. */ +export class Metrics extends Port("Metrics")<{ + readonly count: (name: string) => void; +}> {} + +/** A set port. Fails additionally on `MANY` when `ManyPortClass` is not nameable. */ +export class Subscribers extends Port.many("Subscribers")<{ + readonly topic: string; + readonly handle: (order: Order) => void; +}> {} + +/** A port whose service shape reaches through another port's `ServiceOf`. */ +export class Auditor extends Port("Auditor")<{ + readonly orders: ServiceOf; + readonly record: (order: Order) => AsyncResult; +}> {} + +/** A port re-declared over a shape imported from the example proper. */ +export class OrderCache extends Port("OrderCache")<{ + readonly peek: (id: string) => Order | undefined; +}> {} + +/* ── Everything downstream of a port, also emitted ────────────────────────── */ + +export const MetricsProvider = Provider(Metrics)({ value: { count: () => {} } }); + +export const SubscriberProvider = Provider.member(Subscribers)({ + value: { topic: "orders", handle: () => {} }, +}); + +export const ObservabilityModule = Module("Observability")({ + provides: [ + MetricsProvider, + SubscriberProvider, + Provider(OrderCache)({ value: { peek: () => undefined } }), + Provider(Auditor)([OrderRepository], { + sync: (orders) => ({ orders, record: () => Ok(undefined).toAsync() }), + }), + ], + exports: [Metrics, Subscribers, OrderCache, Auditor], +}); + +/** A `Module<…>` whose inferred type names port instances in its type arguments. */ +export const AppModule = makeAppModule(InMemoryPersistenceModule); + +/** `ServiceOf` on the class and on the instance, both emitted. */ +export const subscribers: ServiceOf = []; +export const metrics: ServiceOf = { count: () => {} }; +export declare const getOrder: ServiceOf; + +/** A union of port instance types — what a `Module`'s `Exports` channel is. */ +export type Vocabulary = Metrics | Auditor | OrderCache; + +/** A helper generic over `AnyPort`: its inferred return type names the port. */ +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); diff --git a/examples/hexagonal-order-api/tsconfig.emit.json b/examples/hexagonal-order-api/tsconfig.emit.json new file mode 100644 index 0000000..9e53c0e --- /dev/null +++ b/examples/hexagonal-order-api/tsconfig.emit.json @@ -0,0 +1,14 @@ +// The declaration-emit gate. `TS4020` and friends are raised by the +// *declaration emitter*, so the package's ordinary `tsc --noEmit` pass cannot +// see them: `noEmit` has to come back off and the emitter has to actually run. +// `src/emit-guards.ts` is the fixture it exists for — see that file's header. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "outDir": "./node_modules/.emit-check" + } +} diff --git a/examples/hexagonal-order-api/tsconfig.json b/examples/hexagonal-order-api/tsconfig.json index 5baf2da..f78f9b9 100644 --- a/examples/hexagonal-order-api/tsconfig.json +++ b/examples/hexagonal-order-api/tsconfig.json @@ -2,21 +2,7 @@ "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { "rootDir": "./src", - "types": ["node"], - // This package is private and never emits: no build script, nothing - // ever runs `tsc` in emit mode against it. Left on (the base's default), - // `declaration: true` alone — even under `--noEmit` — makes the checker - // verify every exported symbol is *nameable* in a declaration file, and - // an exported `class X extends Port(id) {}` fails that: `Shape`'s - // base type is `PortInstance`, keyed on two `unique symbol`s - // (`ID`/`SERVICE`) that `@btravstack/di` deliberately never exports — - // confirmed present, still unexported, in its own built `dist/index.d.mts` - // (`pnpm --filter @btravstack/di build`). TS4020 ("has or is using - // private name 'ID'") is the result. Turned off here because nothing in - // this package needs it; see the top-level report for why this is worth - // knowing about beyond just this workaround. - "declaration": false, - "declarationMap": false + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "src/**/*.test-d.ts"] diff --git a/examples/plugin-registry/tsconfig.json b/examples/plugin-registry/tsconfig.json index 829f016..7fa4349 100644 --- a/examples/plugin-registry/tsconfig.json +++ b/examples/plugin-registry/tsconfig.json @@ -2,13 +2,7 @@ "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { "rootDir": "./src", - "types": ["node"], - // This package is private and never emits declarations — see the - // identical, longer comment in the hexagonal-order-api example's - // tsconfig.json for why leaving `declaration: true` on trips TS4020 the - // moment a `Port`-derived class is exported. - "declaration": false, - "declarationMap": false + "types": ["node"] }, "include": ["src/**/*"] } diff --git a/examples/request-scope/tsconfig.json b/examples/request-scope/tsconfig.json index 829f016..7fa4349 100644 --- a/examples/request-scope/tsconfig.json +++ b/examples/request-scope/tsconfig.json @@ -2,13 +2,7 @@ "extends": "@btravstack/tsconfig/base.json", "compilerOptions": { "rootDir": "./src", - "types": ["node"], - // This package is private and never emits declarations — see the - // identical, longer comment in the hexagonal-order-api example's - // tsconfig.json for why leaving `declaration: true` on trips TS4020 the - // moment a `Port`-derived class is exported. - "declaration": false, - "declarationMap": false + "types": ["node"] }, "include": ["src/**/*"] } diff --git a/knip.jsonc b/knip.jsonc index 0a34eb8..2c4df92 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -1,12 +1,14 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - // `PortClass` and `ManyPortClass` are exported from `port.ts` and consumed - // inside it — as `PortDeclaration`'s and `Port.many`'s own return-type - // annotations — and nowhere else (neither re-exported from `index.ts` nor - // imported by another module). This setting says "used inside its own file - // still counts", which is the truth here and what the sibling repos - // configure (see `@btravstack/entity`'s identical setting and rationale). + // "Used inside its own file still counts." This was introduced for + // `PortClass`/`ManyPortClass`, which were exported from `port.ts`, consumed + // inside it as `PortDeclaration`'s and `Port.many`'s own return-type + // annotations, and read by nothing else. `index.ts` now re-exports both — the + // declaration-emit fix — so nothing in this repo currently depends on the + // setting (measured: knip reports the same with it off). Kept because it is + // what the sibling repos configure and the next port-internal type alias will + // want it again, not because something is relying on it today. "ignoreExportsUsedInFile": true, // Type-level behaviour lives in `*.test-d.ts`, checked by its own tsc pass and @@ -31,4 +33,17 @@ // `lefthook.yml`'s `extends`. Knip resolves neither of these config // formats, so it reads both as unused devDependencies. "ignoreDependencies": ["@btravstack/lefthook", "@btravstack/oxlint"], + + "workspaces": { + "examples/hexagonal-order-api": { + // `emit-guards.ts` is deliberately imported by nothing. It exists to be + // COMPILED: it is the declaration-emit fixture that keeps TS4020 from + // coming back, and its assertions are `@ts-expect-error` directives with + // no runtime moment. Naming it an entry is what stops knip reporting the + // guard as dead code and someone helpfully deleting it. `src/index.ts` is + // not listed: it is already the package entry. + "entry": ["src/emit-guards.ts"], + "project": ["src/**/*.ts"], + }, + }, } diff --git a/packages/di/src/index.ts b/packages/di/src/index.ts index 74c9b6d..06c67e9 100644 --- a/packages/di/src/index.ts +++ b/packages/di/src/index.ts @@ -11,7 +11,31 @@ export { Port } from "./port.js"; // consumer reaching past the index). Internal modules import the class from // `./port.js` directly, as do the two tests that exist to prove the runtime // check still fires (`scoped.spec.ts`). -export type { AnyPort, Scope, ServiceOf } from "./port.js"; +// +// `PortClass`/`ManyPortClass` are exported for declaration emit, not because a +// consumer is expected to write either by hand. `class OrderRepository extends +// Port("OrderRepository") {}` — the pattern the README teaches — emits as +// `declare const OrderRepository_base: `, +// and the emitter can only write that type using names the consumer can reach. +// With these two unexported it had none: it expanded the heritage expression +// down to `PortInstance`'s `[ID]`/`[SERVICE]` keys, which are module-private +// `unique symbol`s, and every consumer that *exported* a port failed with +// TS4020 ("has or is using private name 'ID'"). Naming the class types is the +// fix that costs least: the emitter stops at `PortClass<"OrderRepository">` +// (measured: 2,683 bytes of consumer declarations across the reproduction, +// against 3,545 when only the instance types are nameable and the emitter has +// to write the construct signature out). +// +// The symbols themselves stay unexported deliberately. They are what makes port +// identity nominal, and a consumer who can name `ID`/`SERVICE` can hand-write +// `{ [ID]: "Logger", [SERVICE]: Shape }` and pass it off as a `Logger` — +// measured, it type-checks. Exporting the class *types* grants no such thing: +// the brand keys stay unnameable, so `PortInstance` values remain unforgeable +// and `MemberOf`'s `[MANY]` discriminant stays unspoofable. `PortInstance` and +// 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"; export { Context } from "./context.js"; export { Provider } from "./provider.js"; export { Module } from "./module.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 131953b..fad5c30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ catalogs: typescript: specifier: 7.0.2 version: 7.0.2 + typescript-consumer: + specifier: npm:typescript@5.9.3 + version: 5.9.3 unthrown: specifier: 5.1.0 version: 5.1.0 @@ -123,6 +126,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + typescript-consumer: + specifier: 'catalog:' + version: typescript@5.9.3 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jiti@2.7.0)(yaml@2.9.0) @@ -2244,6 +2250,11 @@ packages: resolution: {integrity: sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg==} hasBin: true + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -4081,6 +4092,8 @@ snapshots: '@turbo/windows-64': 2.10.8 '@turbo/windows-arm64': 2.10.8 + typescript@5.9.3: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b4f9c0..bdaf65e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -29,6 +29,15 @@ catalog: # that measurement rather than trailing it — see `@btravstack/entity`'s own # catalog comment for the same pin, which this repo follows. typescript: 7.0.2 + # The TypeScript a *consumer* is realistically on, for the second + # declaration-emit pass in `examples/hexagonal-order-api` + # (`tsconfig.emit.json`, compiled once by each version). 7.0.2 is the native + # port and the one this repo builds with; a published package has to be + # readable by the stable line too, and the two emitters do not agree on + # everything. Aliased because one `package.json` cannot name `typescript` + # twice — the same arrangement, and the same alias, as + # `@btravstack/entity`'s `examples/billing-domain`. + typescript-consumer: "npm:typescript@5.9.3" unthrown: 5.1.0 "@vitest/coverage-v8": 4.1.10 vitest: 4.1.10