There's a specific moment in your relationship with TypeScript where you stop fighting it and start getting it. For me it was 2AM on a Tuesday, staring at a production bug that would've been physically impossible with properly defined types. That night changed how I think about code.
This isn't an intro tutorial. If you're still wrestling with interface vs type, there are a thousand articles for that. This is what's actually running in my head when I write TypeScript today — the patterns I use without thinking, the ones that saved my ass more than once, and the mistakes I made before I finally understood them.
Discriminated Unions: the pattern I use most
If I had to keep just one TypeScript pattern, it's this one. The idea is simple: you have a union type where each variant has a discriminant property — usually type or kind — that tells TypeScript exactly what you're working with.
type ApiResponse<T> =
| { status: 'loading' }
| { status: 'error'; error: string; code: number }
| { status: 'success'; data: T; timestamp: Date };
function renderUser(response: ApiResponse<User>) {
switch (response.status) {
case 'loading':
return <Spinner />;
case 'error':
// TypeScript knows response.error and response.code exist here
return <ErrorMessage message={response.error} code={response.code} />;
case 'success':
// TypeScript knows response.data and response.timestamp exist here
return <UserCard user={response.data} />;
}
}
What I love about this is that TypeScript will tell you if you forgot a case. Add 'cancelled' to the union and suddenly the compiler tells you exactly where you need to handle that situation. It's like having a colleague who reviews your code without being annoying about it.
I use this for domain events, UI states, async operation results. In an e-commerce project I did last year, I modeled all the states of an order like this:
type OrderState =
| { kind: 'draft'; items: CartItem[] }
| { kind: 'pending_payment'; orderId: string; total: Money }
| { kind: 'paid'; orderId: string; paymentId: string; paidAt: Date }
| { kind: 'shipped'; orderId: string; trackingCode: string }
| { kind: 'delivered'; orderId: string; deliveredAt: Date }
| { kind: 'cancelled'; orderId: string; reason: string };
Each state carries exactly the information that makes sense for that state. No weird optional fields, no trackingCode: string | null where you can't tell if it's null because the order wasn't shipped yet or because it's some legacy record. The shape of the type is the documentation.
Branded Types: when string isn't enough
This one took me longer to get but now I can't live without it. The problem is simple: userId: string and productId: string are the same type to TypeScript, but they're absolutely not the same thing to your business. Mixing them up is a bug.
// Without branded types — TypeScript won't complain about this:
function getUser(id: string) { /* ... */ }
function getProduct(id: string) { /* ... */ }
const productId = '123';
getUser(productId); // TypeScript says this is fine. It is not fine.
The fix with branded types:
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
type OrderId = Brand<string, 'OrderId'>;
// Constructor functions that validate and brand
function createUserId(id: string): UserId {
if (!id.match(/^usr_[a-z0-9]+$/)) {
throw new Error(`Invalid user ID format: ${id}`);
}
return id as UserId;
}
function getUser(id: UserId): Promise<User> { /* ... */ }
function getProduct(id: ProductId): Promise<Product> { /* ... */ }
const userId = createUserId('usr_abc123');
const productId = 'prod_xyz789' as ProductId;
getUser(productId); // TS Error: Argument of type 'ProductId' is not assignable to parameter of type 'UserId'
getUser(userId); // OK
The __brand property never exists at runtime — it's pure fiction for the type checker. Zero cost in production, massive benefit in development.
I also use this for primitive values with specific semantics:
type Percentage = Brand<number, 'Percentage'>;
type Milliseconds = Brand<number, 'Milliseconds'>;
type USD = Brand<number, 'USD'>;
function calculateDiscount(price: USD, discount: Percentage): USD {
return (price * (1 - discount / 100)) as USD;
}
// You can't accidentally pass milliseconds as a price
const delay: Milliseconds = 5000 as Milliseconds;
const price: USD = 99.99 as USD;
calculateDiscount(delay, price); // Compile-time error, not a production incident
Advanced Generics: beyond Array<T>
Generics is where TypeScript gets really powerful and where most people get lost. I got lost plenty of times. Here are the patterns that stuck around in my toolbelt.
Conditional Types
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Real usage: when you need to handle values that can be async or sync
type MaybeAsync<T> = T | Promise<T>;
type Resolved<T> = T extends Promise<infer U> ? U : T;
// Unwrap nested arrays
type Flatten<T> = T extends Array<infer U> ? U : T;
type StringOrNumber = Flatten<string[]>; // string
type JustString = Flatten<string>; // string
Template Literal Types
This one genuinely blew my mind when I discovered it. You can do string arithmetic inside the type system:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
type ApiEndpoint = '/users' | '/products' | '/orders';
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`;
// = 'GET /users' | 'GET /products' | 'GET /orders' | 'POST /users' | ...
// I use this for event names in event-driven systems
type EntityName = 'user' | 'product' | 'order';
type CrudAction = 'created' | 'updated' | 'deleted';
type DomainEvent = `${EntityName}.${CrudAction}`;
// = 'user.created' | 'user.updated' | 'user.deleted' | 'product.created' | ...
type EventHandler<T extends DomainEvent> = (event: T) => void;
function on<T extends DomainEvent>(event: T, handler: EventHandler<T>) {
// register the handler
}
on('user.created', (event) => { /* event is 'user.created' */ });
on('invalid.event', () => {}); // Compile-time error
Mapped Types with modifiers
// The classic DeepReadonly that's not in the stdlib
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// DeepPartial for forms
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Pick with dot notation — I wrote this for a form builder
type PathsToString<T> = T extends string | number | boolean
? never
: {
[K in keyof T & string]: K | `${K}.${PathsToString<T[K]>}`;
}[keyof T & string];
How I think about types: the mental shift
I used to think of types as annotations — write the code first, slap types on top after. That's a huge conceptual mistake. Now I think types first, especially at the domain layer.
Your types are your business model. If the type compiles, the business invariants hold — or they should. If you can construct an invalid state with your types, the types are wrong.
Concrete example: a shopping cart can't have a negative item quantity. If you have quantity: number, you're lying. You have quantity: PositiveInteger or you have a bug waiting to happen.
type PositiveInteger = Brand<number, 'PositiveInteger'>;
function toPositiveInteger(n: number): PositiveInteger {
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Expected positive integer, got: ${n}`);
}
return n as PositiveInteger;
}
interface CartItem {
productId: ProductId;
quantity: PositiveInteger;
unitPrice: USD;
}
Now it's literally impossible to have a CartItem with zero or negative quantity without going through the function that validates it. The validation lives in exactly one place.
The Result pattern that replaced my try/catch
I borrowed this from Rust and it changed how I handle errors entirely:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
// Instead of throwing:
async function fetchUser(id: UserId): Promise<Result<User, 'NOT_FOUND' | 'NETWORK_ERROR'>> {
try {
const user = await db.users.findById(id);
if (!user) return err('NOT_FOUND');
return ok(user);
} catch {
return err('NETWORK_ERROR');
}
}
// At the call site, TypeScript forces you to handle both cases:
const result = await fetchUser(userId);
if (!result.ok) {
switch (result.error) {
case 'NOT_FOUND': return redirect('/404');
case 'NETWORK_ERROR': return showRetryButton();
}
}
// Here TypeScript knows result.value exists and is User
console.log(result.value.name);
The possible errors are right there in the function signature. You don't have to read the implementation to know what can go wrong. That's the difference between documentation that rots and types that are true by construction.
What I don't use
Being straight with you: I don't use any except for interop with legacy libraries, and I always encapsulate it. unknown is almost always the right answer when you genuinely don't know the type. I don't use as except in branded type constructor functions and when I know exactly what I'm doing. If you find yourself reaching for as repeatedly just to make code compile, the types are wrong — not the compiler.
I also avoid types so complex that nobody can read them. A type that needs a comment to explain itself has already failed at its main job. If you hit four levels of nested conditional generics, stop for a second and think about whether there's a simpler abstraction. There almost always is.
The journey is worth it
I remember clearly when TypeScript felt like unnecessary bureaucracy. "Why bother with types if JavaScript works anyway?" — that's the question from someone who hasn't had the production bug painful enough yet.
Today I can't imagine building a serious application without it. Not because it's a rule, but because when the types are right, the code talks to you. You refactor with confidence because the compiler tells you exactly what you broke. You step into someone else's codebase and the types tell you the business model without having to read outdated comments.
Start with discriminated unions. That's my actual advice. It has the best complexity-to-value ratio of any TypeScript pattern, and once you internalize it, you start seeing opportunities to use it everywhere.
Top comments (0)