Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/fresh-pears-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@btravstack/di": patch
---

Fix `TS4020` in consumers that export a port. `export class OrderRepository extends
Port("OrderRepository")<Shape> {}` — 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.
3 changes: 2 additions & 1 deletion examples/hexagonal-order-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -23,6 +23,7 @@
"@types/node": "catalog:",
"@unthrown/vitest": "catalog:",
"typescript": "catalog:",
"typescript-consumer": "catalog:",
"vitest": "catalog:"
}
}
162 changes: 162 additions & 0 deletions examples/hexagonal-order-api/src/emit-guards.ts
Original file line number Diff line number Diff line change
@@ -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<OrderRepository>;
readonly record: (order: Order) => AsyncResult<void, never>;
}> {}

/** 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<typeof Subscribers> = [];
export const metrics: ServiceOf<Metrics> = { count: () => {} };
export declare const getOrder: ServiceOf<GetOrder>;

/** 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 = <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);
14 changes: 14 additions & 0 deletions examples/hexagonal-order-api/tsconfig.emit.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
16 changes: 1 addition & 15 deletions examples/hexagonal-order-api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)<Shape> {}` fails that: `Shape`'s
// base type is `PortInstance<Id, Service>`, 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"]
Expand Down
8 changes: 1 addition & 7 deletions examples/plugin-registry/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"]
}
8 changes: 1 addition & 7 deletions examples/request-scope/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**/*"]
}
27 changes: 21 additions & 6 deletions knip.jsonc
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"],
},
},
}
26 changes: 25 additions & 1 deletion packages/di/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")<Shape> {}` — the pattern the README teaches — emits as
// `declare const OrderRepository_base: <the type of the heritage expression>`,
// 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";
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading