A String That Knows Its Type
The first lie I want to fix is the simplest one: a function that takes a collection name as a string and a document type as a generic, with nothing connecting the two.
const orders = readCollection<OrderDoc>('customers'); // compiles, reads the wrong collection
By the end of this post, that line will have nowhere to hide: an explicit type argument on readCollection will visibly mean "I am overriding the registry", and the correct line will need no type argument at all:
const orders = readCollection('orders'); // Collection<OrderDoc>, inferred from the name
The mechanics are three small TypeScript features composed carefully: closed string unions, literal type inference, and overload ordering. Each is unremarkable alone. The design is in how they fail together, gracefully, when someone steps outside the registry.
Step one: close the world
The foundation is refusing to let collection names be string. A production system knows its collections; that knowledge should live in one place, as a closed union:
export type CollectionName =
| 'orders'
| 'customers'
| 'invoices'
| 'shipments'
| 'audit-logs';
A closed union looks like documentation. It is actually an enforcement mechanism, because of what Record does with it. In our system every collection needed a security classification (which field carries the tenant scope, a topic for another day). Keying that catalog by the union makes it exhaustive by construction:
export const COLLECTION_CATALOG: Record<CollectionName, CollectionPolicy> = {
orders: { /* ... */ },
customers: { /* ... */ },
invoices: { /* ... */ },
shipments: { /* ... */ },
'audit-logs': { /* ... */ },
};
Add 'refunds' to the union and this file stops compiling until someone classifies it. That is the whole trick, and it is worth pausing on: you did not write a test, a lint rule, or a startup check. You chose a shape the compiler refuses to leave incomplete. Adding a collection without deciding its policy is now unrepresentable.
Constants that carry names around need one more ingredient. This loses the information:
export const ORDERS_COLLECTION: CollectionName = 'orders'; // type: CollectionName
Annotating declares the constant as the whole union, and the registry lookup we are about to build needs the literal. (Inside the declaring file you may not notice: the compiler's flow analysis still remembers the initializer was 'orders'. The moment another module imports the constant, which is the entire point of exporting it, that memory is gone and only the annotation remains.) The tool for "check it, but keep it narrow" is satisfies:
export const ORDERS_COLLECTION = 'orders' satisfies CollectionName; // type: 'orders'
satisfies validates the value against the union and then gets out of the way, leaving the literal type intact. This distinction between checking a type and ascribing a type shows up everywhere in this series; satisfies checks, a colon ascribes.
Step two: a registry from names to types
Now the interesting part. We want a type-level map from each name literal to its document type:
export interface CollectionDocRegistry {
orders: OrderDoc;
customers: CustomerDoc;
invoices: InvoiceDoc;
shipments: ShipmentDoc;
}
export type RegisteredCollectionName = keyof CollectionDocRegistry & CollectionName;
(Why an interface and not a type, and why audit-logs is missing, are the subject of the next seam; for now, treat it as a plain map. The intersection with CollectionName guarantees nobody registers a name that does not exist.)
With the map in hand, the lookup is one overload:
export function readCollection<K extends RegisteredCollectionName>(
name: K,
): Collection<CollectionDocRegistry[K]>;
export function readCollection<T extends Document>(name: string): Collection<T>;
export function readCollection(name: string): Collection<Document> {
return provider.collection(name);
}
Read the first overload slowly, because all the value is in it. K is constrained to the registered names, so when you call readCollection('orders'), TypeScript infers K = 'orders' (the literal survives because the constraint is a union of literals), and the return type is computed by an indexed access: CollectionDocRegistry['orders'], which is OrderDoc. The name is no longer a runtime detail the types ignore. The name is the type key.
And now the lie from the opening cannot be written. readCollection<OrderDoc>('customers') fails overload one, because OrderDoc is not a registered name; it falls to overload two, where you are visibly, explicitly asserting a type by hand, in the escape hatch designed for that. The dishonest call did not become an error everywhere; it became loud. For registered names used plainly, there is nothing left to get wrong.
Step three: design the failure modes
The second overload is not a leftover. It is a load-bearing design decision, and the reason this pattern can land in a legacy codebase without a flag day.
Overloads resolve top to bottom, first match wins. That gives you a precise routing table:
readCollection('orders'): matches overload one, fully typed, no annotations.readCollection(someDynamicString): a plainstringdoes not extend the registered union, falls to overload two, types as it always did.readCollection<LegacyDoc>('orders'): an explicit type argument that is not a registered name fails overload one and lands on overload two. Every pre-existing call site in the codebase keeps compiling with exactly the type it had before.
That last bullet is the migration story. We changed the signature of a function with dozens of call sites and did not touch a single one to keep the build green. Then we migrated them incrementally, and here is the satisfying part: deleting the explicit type argument at a call site is itself a test. Collection<T> is invariant in T in every position that matters, so if the inferred type from the registry did not exactly match what the call site used to assert, the surrounding code would stop compiling. Every annotation we deleted was the compiler confirming that name and type finally agree.
One more failure mode deserves attention because everyone forgets it: what if the registry is empty? (It will be, briefly, in any codebase that adopts this incrementally, and permanently in a shared library before anyone registers.) Then keyof CollectionDocRegistry is never, so RegisteredCollectionName is never, so overload one can never match, and every call flows to the escape hatch. The system degrades to exactly the old behavior. No special case, no conditional types checking for emptiness. The degenerate case was designed to be the legacy case.
What this buys, concretely
After the migration, three properties hold in that codebase and are enforced by the compiler, not by review:
- A new collection cannot exist without a policy entry (the exhaustive catalog).
- A registered collection read from the typed doorway cannot claim a wrong type (the name is the key).
- Code outside the registry is not broken, but it is visible: an explicit generic on
readCollectionis now a grep-able signal meaning "this call site has not been migrated or cannot be".
Notice the shape of the win. We did not add validation. We removed the degrees of freedom where the bug lived. The string and the type used to be two independent claims; now there is one claim, and the other is derived.
There is one thing I deliberately hand-waved: that registry interface lives in a shared kernel package that is architecturally forbidden from importing OrderDoc, CustomerDoc, or any other business type. A map from names to business types, in a layer that must not know business types, sounds impossible. It is not, and the mechanism that resolves it, module augmentation used as dependency injection for types, is where this series goes next.