Dependency Injection, but for Types
Every layered backend eventually states a rule like this one: the shared kernel must not import business code. Ours did. The kernel package holds the base repository, the query helpers, the read-model facade, the security machinery; the business layer holds the entities, the schemas, the concrete repositories. The dependency arrow points one way, business depends on kernel, and an architecture test fails the build if any kernel file imports from the business side. The rule is boring and good.
Then the registry pattern from the previous seam arrives and walks straight into it. A map from collection names to document types:
export interface CollectionDocRegistry {
orders: OrderDoc; // OrderDoc is a business type
customers: CustomerDoc; // so is this
}
This interface has to be visible to readCollection, which lives in the kernel. But its values are business types the kernel is forbidden to import. The naive placements both lose: put the map in the kernel and you have just made the kernel depend on every entity in the system (the architecture test fails, and it is right to); put the map and a typed wrapper in the business layer and you now have two doorways to the same data, with half the codebase importing the wrong one within a month.
At runtime, this tension is ancient and solved. It is called dependency injection: the kernel declares an interface, the business layer provides the implementation, and something wires them together at composition time. Our kernel already worked that way for values; its read-model facade does not import the security catalog, it receives it at bootstrap:
initReadModelSource(mongoProvider, getCollectionPolicy); // wiring, in the composition root
What took me embarrassingly long to see is that TypeScript has the same mechanism for types. It is called module augmentation, it ships with the language, and almost nobody talks about it as a DI tool.
The injection point: an empty interface
In the kernel, the registry is declared empty:
// kernel/collection-doc-registry.ts
/** Filled by the business layer via module augmentation. */
export interface CollectionDocRegistry {}
export type RegisteredCollectionName = keyof CollectionDocRegistry & CollectionName;
export type RegisteredDoc<K extends RegisteredCollectionName> =
CollectionDocRegistry[K] & BaseDoc;
An empty interface exported on purpose is a strange-looking artifact, so it deserves its comment. It is not a placeholder someone forgot to fill. It is a socket: the declared shape of a dependency the kernel needs but must not construct. The kernel code (readCollection's typed overload) is written entirely against this empty socket and compiles fine, because everything it does is expressed through keyof and indexed access, which are meaningful, if vacuous, on an empty interface.
The reason this must be an interface and not a type alias is the whole trick: interfaces in TypeScript are open. Multiple declarations of the same interface, even across files, merge into one. A type alias is closed at the point of definition; an interface is a contribution point.
The realization: augmentation from the business layer
The business layer, which is allowed to know every entity, fills the socket:
// business/collection-doc-registry.ts
import type { OrderDoc } from './orders/order.schema.js';
import type { CustomerDoc } from './customers/customer.schema.js';
import type { InvoiceDoc } from './invoices/invoice.schema.js';
declare module '@kernel/collection-doc-registry.js' {
interface CollectionDocRegistry {
orders: OrderDoc;
customers: CustomerDoc;
invoices: InvoiceDoc;
}
}
declare module with the kernel's specifier reopens the kernel's interface and merges these members in. The kernel file is not edited. The kernel still imports nothing. But everywhere in the compilation, keyof CollectionDocRegistry is now 'orders' | 'customers' | 'invoices', and readCollection('orders') resolves to Collection<OrderDoc>.
Squint and the structure is exactly the runtime DI we already had, translated one level up. The empty interface is the constructor parameter. The declare module block is the binding. The compiler is the container, and the composition happens at build time, program-wide, because the augmentation file is part of the compilation unit. There is even the same discipline about where wiring lives: exactly one augmentation file, sitting next to the schemas, playing the role of the composition root.
A few practical notes from getting this through a real build. The augmentation's module specifier must resolve exactly like an import would, so path aliases work if your tsconfig paths are set up, and the .js extension matters under NodeNext resolution just as it does in imports. The augmentation applies as long as the file is in the program (your include covers it); nothing needs to import it at runtime, and since it contains only type imports, it emits nothing anyway. And your dependency-direction architecture test stays fully green: the type imports all live on the business side, which is the side allowed to have them.
Trust, but verify: asserting the injected shape
Runtime DI containers validate their bindings. Type-level DI should too, because the socket is open: nothing in the mechanism itself stops a colleague from registering a key that is not a real collection name, or a value that is not actually a document type. The kernel cannot check this (it cannot name the business types to check them), but the realization site can, with a pattern worth having in your toolbox:
type Assert<T extends true> = T;
export type RegistryKeysAreCollectionNames = Assert<
Exclude<keyof CollectionDocRegistry, CollectionName> extends never ? true : false
>;
export type RegistryValuesAreDocs = Assert<
CollectionDocRegistry[keyof CollectionDocRegistry] extends BaseDoc ? true : false
>;
Assert is a type alias whose constraint does the work: instantiating it with anything but true is a compile error, at this line, in this file. The first assertion says "no registered key falls outside the closed union of collection names" (Exclude of the keys against the union must leave nothing). The second says "the union of all registered values extends the base document shape", which holds only if every individual value does. Register a typo'd key or a non-document value, and the build fails here, at the declaration site, with these names in the error message, instead of somewhere downstream in a confusing indexed-access error.
This also answers the question I left dangling earlier: why audit-logs is absent from the registry. In our system, audit log entries deliberately do not carry the base document lifecycle, so the second assertion would reject them, and the assertion is right. They stay on the explicit-generic escape hatch, which is precisely the honest place for a collection that does not fit the common contract. A well-designed registry is allowed to say no.
When the socket stays empty
The degradation story from the registry pattern gets stronger under DI, because now "registry is empty" is a real deployment state, not a hypothetical: any consumer that includes the kernel without a realization (a fresh service, a test harness, the kernel's own unit tests) compiles against the empty interface. keyof {} is never, the typed overload becomes unmatchable, and every call falls through to the explicit-generic escape hatch. The kernel's own test suite, which by the architecture rule cannot import business fixtures, exercises exactly this state without knowing it is special.
That is the property I would highlight if I could only keep one: the pattern has no failure cliff. Fully wired, you get inference. Partially wired, the unwired names get the old behavior. Unwired, everything gets the old behavior. At no point does anyone get a wrong type silently; precision is added collection by collection, and each addition is checked by the assertions the moment it lands.
The registry solved names. But the layer boundary shows up again, harder, in the next seam: the repository factory, where the type we want cannot just be looked up, it has to be computed from a zod schema, and where the computed answer is sometimes deliberately wrong and needs an override. Inference with an escape hatch, again, but this time the escape hatch is a design decision about which of two true types is the public one.