Every developer on our team uses TypeScript. But there is a wide gap between using TypeScript and using it well. In June we dedicated our learning time to closing that gap — moving from “annotate everything with types” to “design types that prevent bugs.”
The problem with basic typing
Most TypeScript codebases start the same way. You add types to function parameters and return values. You create interfaces for your data shapes. The compiler catches typos and missing properties. It feels productive.
But the bugs that slip through are not typos. They are logic errors — impossible states that your types allow to exist.
Making invalid states unrepresentable
Consider a component that shows a loading spinner, content, or an error message. The naive type:
interface State {
isLoading: boolean;
data: string | null;
error: string | null;
}
This type allows { isLoading: true, data: "hello", error: "failed" } — a state where you are simultaneously loading, have data, and have an error. It should be impossible, but the type permits it.
A discriminated union eliminates this:
type State =
| { status: 'loading' }
| { status: 'success'; data: string }
| { status: 'error'; error: string };
Now the compiler enforces that you can only access data when the status is success and error when the status is error. The impossible state cannot be constructed.
Branded types for primitive obsession
IDs look the same at the type level. A userId and an orderId are both strings. Nothing stops you from passing one where the other is expected.
Branded types fix this:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
Now getUser(orderId) is a compile-time error. The runtime cost is zero — brands exist only in the type system.
We apply this pattern to all external identifiers: API keys, database IDs, slugs, and tokens.
Exhaustive switch statements
When you switch over a discriminated union, TypeScript can ensure you handle every case:
function render(state: State) {
switch (state.status) {
case 'loading': return renderSpinner();
case 'success': return renderData(state.data);
case 'error': return renderError(state.error);
default: {
const _exhaustive: never = state;
return _exhaustive;
}
}
}
If you add a new status variant later, the compiler immediately flags every switch statement that does not handle it. This turns a runtime bug — a case you forgot — into a compile-time error.
Template literal types for string patterns
APIs often return strings with predictable patterns. TypeScript can enforce them:
type HexColor = `#${string}`;
type ApiRoute = `/api/v${number}/${string}`;
type EventName = `on${Capitalize<string>}`;
These are lightweight guardrails that catch formatting mistakes without runtime validation.
The cost of any
Every any in your codebase is a hole in the type system. We track any usage with a custom ESLint rule and review any new occurrences in pull requests. When you genuinely need to escape the type system, unknown is almost always the correct choice — it requires you to narrow the type before using the value.
What changed after June
Our bug rate on TypeScript projects dropped noticeably after we adopted these patterns. Not because TypeScript catches all bugs — it does not — but because designing types thoughtfully forces you to think about edge cases before writing implementation code.
The types become documentation. A new developer reading a discriminated union understands the possible states immediately, without reading the implementation. That is the real payoff.