The Day I Stopped Trusting My Own Types
There is a particular kind of code review comment that should worry you more than any bug report: "why is there a cast here?" Not because casts are evil, but because every as in a TypeScript codebase is a place where someone decided the compiler could no longer help them. One cast is a pragmatic escape hatch. Two hundred casts are a design verdict.
This series comes from a real refactoring campaign on a production backend: a multi-tenant B2B platform, Node and TypeScript, MongoDB underneath, zod schemas at the boundaries. The codebase was healthy by most measures: well tested, actively maintained, strict mode on. And still, when I actually sat down and audited how types flowed through the persistence layer, I found that the type system had been quietly designed out of the loop. The code worked. The types lied.
One note on the code you will read, here and in the rest of the series. Every snippet is a toy example on purpose: entities are renamed (orders, customers, invoices stand in for the real domain) and trimmed to the minimum that shows the mechanics, because the point is the pattern, not the product's internals. What is not invented is the scenarios: each example is the skeleton of a problem that actually happened in that codebase, kept faithful enough that the failure, and why the fix works, survive the translation.
I want to walk through what "types that lie" look like in practice, because none of these examples came from careless code. Each one was a reasonable decision at the time, and that is exactly what makes this failure mode dangerous.
Exhibit one: the string and the type that never met
The read side of the system fetched MongoDB collection handles through a small facade:
export function readCollection<T>(name: string): Collection<T> {
return provider.collection<T>(name);
}
Callers used it like this:
const orders = readCollection<OrderDoc>('orders');
Looks fine. Now look closer: 'orders' and OrderDoc are two completely independent promises. The string picks the physical collection at runtime. The type parameter picks what the compiler believes about it. Nothing connects them. This compiles without a warning:
const orders = readCollection<OrderDoc>('customers'); // reads customers, typed as orders
That exact class of bug had already happened in this codebase: an analytics module reading one collection while its types claimed another, discovered months later during an unrelated audit. The compiler never had a chance, because the API design made the lie unrepresentable to the compiler while being perfectly representable in the code.
Exhibit two: the double type argument
The repositories were built by a factory that took a zod schema and a collection name:
export function createRepository<E extends BaseEntity, S extends ZodEntitySchema>(
name: CollectionName,
schema: S,
) { /* returns a repository class for E validated by S */ }
Every call site had to supply both the entity type and the schema type:
const OrderRepoBase = createRepository<OrderEntity, typeof orderSchema>('orders', orderSchema);
Here is the thing: in this codebase, OrderEntity is defined as the inferred output of orderSchema. The two type arguments are not two pieces of information. They are one piece of information written twice, and the compiler will happily accept them crossed:
const Broken = createRepository<OrderEntity, typeof customerSchema>('orders', customerSchema);
An entity of one aggregate, a schema of another, a collection name of a third. Three independent knobs for what should be a single fact. Nobody had crossed them yet. The API was patiently waiting for someone to.
Exhibit three: the projection type everyone had to cheat around
The most interesting lie was the subtle one. The codebase had a genuinely good idea: a type-level model of MongoDB projections. Pass a projection object, get back the type of the projected result:
type ApplyProjection<T, P> = {
[K in keyof P & keyof T]: P[K] extends 1
? T[K]
: T[K] extends object
? P[K] extends object
? ApplyProjection<T[K], P[K]>
: never
: never;
};
The intent is lovely: if you project { name: 1 }, the result type should contain name and nothing else, so touching an unprojected field is a compile error. And in one method of one repository, threaded with care, it actually worked.
Everywhere else, it collapsed. Used with a non-literal projection, or with its default parameter, this type evaluates most fields to never. And a value typed never has a property that makes the failure invisible: never is assignable to everything. The broken result type slid through every constraint, every return type, every assignment. The code compiled beautifully. It compiled because the type had degenerated into something that could not disagree with anything.
The tell was downstream. Service code consuming these results was full of this:
const result = page.data[0] as unknown as OrderDetails; // TODO: implement proper typing here
That as unknown as chain is the sound a type system makes when it has given up. The projection type was not helping anyone; it was a formality that every consumer immediately overrode, with a TODO confessing the debt.
Casts are symptoms, not sins
It is tempting to read all of this as sloppiness, and to prescribe discipline: review harder, forbid as, add a lint rule. I think that misses the actual lesson.
Every one of these lies exists because the correct thing was impossible to express and the incorrect thing was easy. The string-keyed handle had no mechanism to connect names to types. The factory had no way to derive the entity from the schema. The projection type punished precision with never and rewarded giving up with a cast. People did not abandon the type system out of laziness. The type system abandoned them first, and the casts are just where the bodies are buried.
Which flips the prescription. You do not fix a lying type system with discipline. You fix it by redesigning the seams so that:
- The correct usage is inferred, requiring zero annotations at the call site.
- The incorrect usage is unrepresentable, failing to compile rather than failing in review.
- When precision is genuinely impossible, the types degrade honestly to something wider, never to a silent lie.
That third point matters as much as the first two, and it is the one most type-level programming articles skip. Real codebases have imprecise corners: dynamic pipelines, legacy call sites, gradual migrations. A type design that only handles the precise cases will be routed around exactly where you need it most.
Where this series goes
Over the next posts I will rebuild each of these seams the way we eventually did in production, with the actual TypeScript mechanics spelled out: why the naive versions fail, what the compiler is really doing, and why the working patterns work.
The ingredients, by name: closed string unions and exhaustive catalogs that make a plain string carry its own type; module augmentation used as dependency injection across architectural layers, so a shared kernel can resolve business types it is forbidden to import; homomorphic mapped types with key remapping, which is how a projection type keeps optionality and readonly modifiers instead of destroying them; inference of literal types through generic constraints, which is why { name: 1 } can stay { name: 1 } instead of widening to { name: number }; and type-level tests wired into the build, so the contracts we win stay won.
None of it requires a framework, a plugin, or a single line of generated code. Just the type system, pointed at the problem instead of away from it.
And because a reasonable reader will eventually ask "why does a codebase deserve this much machinery?", the series ends by zooming out: why a runtime schema (zod, in our case) earns the right to be the single source of truth that validation, API documentation, and persistence all derive from, and why we own a hand-built persistence layer at all instead of using the raw driver or somebody else's abstraction. The type tricks are only defensible if the architecture underneath them is, so I will defend both.
Everything here ended in a satisfying place: the campaign closed with us deleting casts and satisfies annotations from call sites, one by one, and watching the code still compile with the exact same error baseline as before. The compiler took the job back. Next, the first seam: how a string learns what type it is.