Types That Catch Real Bugs
A codebase can be fully annotated and still catch nothing. Types pay in two places — the untrusted boundary and the domain model — and are ceremony almost everywhere else.
A codebase can be fully annotated, pass strict, have no any anywhere, and still catch approximately nothing. That is the normal outcome, and it happens because the types describe the shape the data already had rather than the rules it is supposed to obey.
Annotated JavaScript
Here is the shape almost every project starts with:
interface Order {
id: string;
status: string;
paidAt?: Date;
refundReason?: string;
shippedAt?: Date;
}Every field is typed. Nothing is any. And the type permits an order that is status: "pending" with a refundReason and a shippedAt and no paidAt — a state that cannot exist in the business and which the compiler will happily wave through. It also permits status: "shpped", because the type is string and a typo is a string.
The type is a description of the database row. What you wanted was a description of the domain. Those are different documents, and only one of them prevents bugs.
Make the illegal states unrepresentable
The same model as a discriminated union, where each state carries exactly the data that state has:
type Order =
| { status: "pending"; id: OrderId; placedAt: Date }
| { status: "paid"; id: OrderId; placedAt: Date; paidAt: Date }
| { status: "shipped"; id: OrderId; placedAt: Date; paidAt: Date; shippedAt: Date }
| { status: "refunded"; id: OrderId; placedAt: Date; paidAt: Date; reason: string };Now a pending order with a refund reason is not a bug to be found in review, it is a program that does not compile. And reading a field is gated on proving which state you are in:
function receiptLine(order: Order) {
if (order.status === "pending") return "Awaiting payment";
return `Paid ${order.paidAt.toISOString()}`; // narrowed: paidAt exists
}The compiler knows paidAt is present because you eliminated the one state where it is not. No optional chaining, no non-null assertion, no defensive if that never fires.
The same move handles the type everyone writes badly — the async result. A single object with loading, data and error all optional has eight combinations, of which three are meaningful. As a union it has three, and rendering it becomes a switch that the compiler checks is exhaustive.
Branded types, so ids stop being interchangeable
This function is fully typed and completely unsafe:
function refund(orderId: string, userId: string): Promise<void>
refund(user.id, order.id); // compiles. wrong arguments. ships.Every id in your system is a string, so the type system considers them all the same thing. Branding makes them distinct at compile time while staying plain strings at runtime:
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type OrderId = Brand<string, "OrderId">;
type UserId = Brand<string, "UserId">;
refund(user.id, order.id); // Type 'UserId' is not assignable to 'OrderId'Zero runtime cost, and it removes an entire category of bug that code review is bad at catching — because transposed arguments look correct. The place to mint the branded value is where the id enters the system: the row mapper, the route parameter parser, the API client. One cast in one place, then the type is honest everywhere downstream.
The same technique is worth applying to any string or number with units or meaning: Cents against Dollars, Email against a raw string, Slug against a title. Money is the one that pays for itself fastest.
Parse, don't validate
Everything above is undone by one habit: telling the compiler what arrived from outside instead of checking.
const body = await req.json() as CreateOrderDto; // a wish, not a factThat assertion is a promise you cannot keep. The value came from a network, and the only thing you know about it is that it was valid JSON. From here the type is a lie that propagates: every function downstream is now typed against data that was never checked, and the failure surfaces three layers away as cannot read property of undefined.
Validate at the boundary with a schema, and derive the type from the schema so the two can never drift:
const CreateOrder = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1),
});
type CreateOrder = z.infer<typeof CreateOrder>; // one source of truth
const body = CreateOrder.parse(await req.json()); // throws here, not in the domainThe principle generalises past HTTP. Every place data crosses into your program is a boundary: request bodies, query parameters, environment variables, webhook payloads, message queue records, third-party API responses, and anything read from a database as JSON. Parse at each of them, and the interior of your program can trust its own types.
Environment variables deserve a specific mention, because they are the boundary everyone forgets. process.env.PORT is string | undefined, and the typical fix is a ! that turns a clear startup failure into a mysterious runtime one. Parse the whole environment once at boot, fail loudly if it is wrong, and export a typed object.
Where as is actually correct
Assertions are not banned, they are a claim that you know something the compiler cannot. That is occasionally true:
- Minting a branded type after validating it — the whole point is that the compiler cannot verify the brand.
- Test fixtures, where you deliberately build a partial object and do not want thirty irrelevant fields.
as const, which is not really an assertion — it narrows literals and is almost always what you wanted.
And the modern replacement for most annotation habits is satisfies, which checks conformance without widening the type:
// annotated: routes.admin is string, the literal is lost
const routes: Record<string, string> = { admin: "/admin" };
// satisfies: checked against the constraint, literal type preserved
const routes = { admin: "/admin" } satisfies Record<string, string>;When types are ceremony
The honest half. Type work has a cost, it is paid by everyone who reads the code afterwards, and past a point it stops buying anything.
- Annotating what is already inferred.
const total: number = a + badds a maintenance point and no information. Annotate function parameters and public return types; let the rest infer. - Generic gymnastics in application code. A conditional type with four nested
inferclauses is a library technique. In a product codebase it is a puzzle that the next person will work around rather than modify. - Typing the shape of a third-party response you do not control. Parse the three fields you use. Do not transcribe their entire schema, which will be wrong by next quarter.
- Interfaces for things with one implementation. An interface exists to allow substitution. Without a second implementation, it is a second file to keep in sync.
A useful test before adding a type: name the bug it prevents. If you can describe a specific wrong program that this type rejects, it is worth having. If the answer is that it documents the code, write a comment — comments are cheaper and do not break the build when the shape changes.
The short version
Model states as unions so the illegal ones do not compile. Brand your identifiers so they stop being interchangeable strings. Parse at every boundary and derive types from the schema rather than asserting. Spend your type budget on the domain model and the edges, and let the middle of the program infer — that is where the bugs are not.