TypeScript Advanced Patterns Every Serious Developer Should Know
A deep dive into advanced TypeScript patterns including discriminated unions, template literal types, branded types, builder pattern, Zod validation, and utility types with practical code examples.
Beyond the Basics
Most TypeScript developers reach a plateau. They understand interfaces, generics, and basic type annotations. They can make the compiler happy. But they never push further into the type system's real power — the patterns that eliminate entire categories of bugs at compile time, make APIs impossible to misuse, and turn the type checker into an active development partner rather than a nagging syntax enforcer.
I hit that plateau about two years into writing TypeScript professionally. My code compiled fine, but runtime errors still showed up — wrong enum values, mismatched IDs, missing cases in switch statements. Then I started learning the advanced patterns, and my runtime errors dropped dramatically. Not because I became a better programmer, but because the compiler was catching mistakes I used to ship.
These are the patterns I wish someone had shown me earlier. Every example comes from real production code (simplified for clarity), and every pattern solves a concrete problem you have probably encountered.
Discriminated Unions: Making Illegal States Unrepresentable
Discriminated unions are the single most useful advanced pattern in TypeScript. They let you model data that can be one of several shapes, with the compiler enforcing correct handling of each shape.
The Problem
// Bad: optional fields create ambiguity
interface ApiResponse {
status: 'loading' | 'success' | 'error';
data?: User[];
error?: string;
}
function handleResponse(response: ApiResponse) {
if (response.status === 'success') {
// TypeScript doesn't know data exists here
console.log(response.data?.length); // forced to use optional chaining
}
}
With optional fields, nothing prevents you from creating a response with status: 'error' and data populated, or status: 'success' with an error message. The type allows impossible states.
The Solution
// Good: discriminated union — each variant has exactly the right fields
type ApiResponse =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function handleResponse(response: ApiResponse) {
switch (response.status) {
case 'loading':
// No data or error available — compiler knows this
showSpinner();
break;
case 'success':
// data is guaranteed to exist — no optional chaining needed
console.log(response.data.length);
renderUsers(response.data);
break;
case 'error':
// error is guaranteed to exist
showError(response.error);
break;
}
}
The status field is the discriminant — it tells TypeScript which variant you are dealing with. Inside each case, the compiler narrows the type automatically. You cannot access response.data in the 'error' case because it does not exist on that variant.
Real-World Example: Payment Processing
type PaymentResult =
| { type: 'success'; transactionId: string; amount: number }
| { type: 'declined'; reason: string; retryable: boolean }
| { type: 'pending'; estimatedCompletionTime: Date }
| { type: 'requires_auth'; redirectUrl: string };
function processPaymentResult(result: PaymentResult): string {
switch (result.type) {
case 'success':
return `Payment of Rs ${result.amount} confirmed. ID: ${result.transactionId}`;
case 'declined':
return result.retryable
? `Payment declined: ${result.reason}. Please try again.`
: `Payment declined: ${result.reason}. Contact your bank.`;
case 'pending':
return `Payment processing. Expected by ${result.estimatedCompletionTime.toLocaleString()}`;
case 'requires_auth':
return `Additional authentication required. Redirecting...`;
}
}
Every possible payment state is modeled with exactly the data it needs. No more checking if transactionId exists before using it. No more wondering if redirectUrl is available.
Template Literal Types: String-Level Type Safety
Template literal types let you create types from string patterns. They are extraordinarily useful for API routes, CSS values, event names, and any scenario where strings follow a predictable pattern.
// Define event name patterns
type EventName = `${'click' | 'hover' | 'focus'}:${'button' | 'input' | 'link'}`;
// Only valid combinations are allowed
const event1: EventName = 'click:button'; // OK
const event2: EventName = 'hover:input'; // OK
// const event3: EventName = 'click:div'; // Error: not assignable
// API route pattern
type ApiRoute = `/api/${'users' | 'posts' | 'comments'}/${string}`;
const route1: ApiRoute = '/api/users/123'; // OK
const route2: ApiRoute = '/api/posts/latest'; // OK
// const route3: ApiRoute = '/api/orders/123'; // Error
// CSS unit values
type CSSLength = `${number}${'px' | 'rem' | 'em' | '%' | 'vh' | 'vw'}`;
const width: CSSLength = '100px'; // OK
const height: CSSLength = '50vh'; // OK
// const bad: CSSLength = '100'; // Error: missing unit
Practical Application: Type-Safe Route Parameters
type ExtractParams<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? { [K in Param | keyof ExtractParams<Rest>]: string }
: T extends `${infer _Start}:${infer Param}`
? { [K in Param]: string }
: {};
// Automatically extract route parameters
type UserRoute = ExtractParams<'/api/users/:userId/posts/:postId'>;
// Result: { userId: string; postId: string }
function buildUrl<T extends string>(
template: T,
params: ExtractParams<T>
): string {
let url: string = template;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, value as string);
}
return url;
}
// Type-safe URL building
const url = buildUrl('/api/users/:userId/posts/:postId', {
userId: '123',
postId: '456'
});
// Missing or extra parameters cause compile errors
Conditional Types: Types That Think
Conditional types follow the pattern T extends U ? X : Y — if type T is assignable to type U, the result is type X, otherwise Y. They enable type-level logic.
// Basic conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // false
// Practical: extract return type of async functions
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type Result1 = UnwrapPromise<Promise<string>>; // string
type Result2 = UnwrapPromise<number>; // number
// Extract array element type
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Item = ElementOf<string[]>; // string
type Obj = ElementOf<User[]>; // User
Conditional Types with Unions
Conditional types distribute over unions, which enables powerful filtering:
// Remove null and undefined from a union
type NonNullable<T> = T extends null | undefined ? never : T;
type Clean = NonNullable<string | null | undefined | number>;
// Result: string | number
// Extract only function types from a union
type FunctionsOnly<T> = T extends (...args: any[]) => any ? T : never;
type Mixed = string | (() => void) | number | ((x: number) => string);
type Funcs = FunctionsOnly<Mixed>;
// Result: (() => void) | ((x: number) => string)
The infer Keyword: Extracting Types from Structures
The infer keyword declares a type variable inside a conditional type, letting you extract inner types from complex structures.
// Extract function parameter types
type Parameters<T> = T extends (...args: infer P) => any ? P : never;
type LoginParams = Parameters<(email: string, password: string) => void>;
// Result: [email: string, password: string]
// Extract the first argument of a function
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type First = FirstArg<(name: string, age: number) => void>;
// Result: string
// Extract props from a React component
type PropsOf<T> = T extends React.ComponentType<infer P> ? P : never;
type ButtonProps = PropsOf<typeof Button>;
// Result: the props interface of the Button component
Deeply Nested Extraction
// Extract the resolved type from a nested Promise
type DeepUnwrap<T> = T extends Promise<infer U> ? DeepUnwrap<U> : T;
type Deep = DeepUnwrap<Promise<Promise<Promise<string>>>>;
// Result: string
// Extract value type from a Map
type MapValue<T> = T extends Map<any, infer V> ? V : never;
type Val = MapValue<Map<string, User>>;
// Result: User
Branded Types: Type-Safe IDs
One of the most common runtime bugs is passing the wrong ID to a function. A userId and a postId are both strings, so TypeScript cannot distinguish them. Branded types fix this.
// Create branded types using intersection with a phantom property
type Brand<T, B> = T & { __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
type CommentId = Brand<string, 'CommentId'>;
// Constructor functions
function UserId(id: string): UserId {
return id as UserId;
}
function PostId(id: string): PostId {
return id as PostId;
}
// Now functions are type-safe
function getUser(id: UserId): Promise<User> {
return fetch(`/api/users/${id}`).then(r => r.json());
}
function getPost(id: PostId): Promise<Post> {
return fetch(`/api/posts/${id}`).then(r => r.json());
}
// Usage
const userId = UserId('user-123');
const postId = PostId('post-456');
getUser(userId); // OK
getUser(postId); // Compile error! PostId is not assignable to UserId
The __brand property never exists at runtime — it is a compile-time-only marker. This zero-cost abstraction prevents an entire class of bugs where you accidentally pass a post ID where a user ID was expected.
Branded Types for Validated Data
type Email = Brand<string, 'Email'>;
type PositiveNumber = Brand<number, 'PositiveNumber'>;
function validateEmail(input: string): Email | null {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(input) ? (input as Email) : null;
}
function validatePositive(input: number): PositiveNumber | null {
return input > 0 ? (input as PositiveNumber) : null;
}
// Functions that accept only validated data
function sendEmail(to: Email, subject: string): void {
// Guaranteed to be a valid email — no need to validate again
}
function setQuantity(qty: PositiveNumber): void {
// Guaranteed to be positive — no need to check again
}
Validation happens once at the boundary, and the branded type carries that guarantee through the rest of your code.
Builder Pattern with Types
The builder pattern combined with TypeScript's type system can ensure that required steps are completed before building an object.
// Type-safe query builder
class QueryBuilder<T extends Record<string, boolean> = {}> {
private query: any = {};
select(fields: string[]): QueryBuilder<T & { select: true }> {
this.query.select = fields;
return this as any;
}
from(table: string): QueryBuilder<T & { from: true }> {
this.query.from = table;
return this as any;
}
where(condition: string): QueryBuilder<T & { where: true }> {
this.query.where = condition;
return this as any;
}
// execute() is only available when both 'select' and 'from' have been called
execute(
this: QueryBuilder<{ select: true; from: true } & Record<string, boolean>>
): string {
return `SELECT ${this.query.select.join(', ')} FROM ${this.query.from}${
this.query.where ? ` WHERE ${this.query.where}` : ''
}`;
}
}
// Valid: select and from are called
new QueryBuilder().select(['name', 'email']).from('users').execute();
// Valid: where is optional
new QueryBuilder().select(['*']).from('posts').where('id = 1').execute();
// Compile error: from() not called
// new QueryBuilder().select(['name']).execute();
The type parameter tracks which methods have been called, and execute() requires specific methods in its this type. The compiler prevents you from executing an incomplete query.
Exhaustive Switch Statements
When you handle a discriminated union with a switch statement, TypeScript can ensure you have covered every case:
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${JSON.stringify(x)}`);
}
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rectangle':
return shape.width * shape.height;
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
If you later add a new shape variant (say 'pentagon'), the assertNever call will cause a compile error because shape in the default case is no longer never — it could be the new unhandled variant. This forces you to add handling for every new case. No more silent bugs from forgotten switch cases.
Zod for Runtime Validation with Type Inference
TypeScript types vanish at runtime. Zod bridges that gap by providing runtime validation that automatically generates TypeScript types.
import { z } from 'zod';
// Define schema — this is your single source of truth
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(13).max(120),
role: z.enum(['admin', 'user', 'moderator']),
preferences: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean(),
language: z.string().default('en')
}).optional()
});
// Infer TypeScript type from schema — no need to define separately
type User = z.infer<typeof UserSchema>;
// Result: {
// id: string;
// name: string;
// email: string;
// age: number;
// role: 'admin' | 'user' | 'moderator';
// preferences?: {
// theme: 'light' | 'dark';
// notifications: boolean;
// language: string;
// }
// }
// Validate at API boundaries
function createUser(input: unknown): User {
const result = UserSchema.safeParse(input);
if (!result.success) {
throw new ValidationError(result.error.issues);
}
return result.data; // Fully typed User object
}
The schema definition serves double duty: it validates data at runtime AND generates the TypeScript type. No more maintaining separate interfaces and validation logic that can drift apart.
Zod with API Responses
const ApiResponseSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
data: z.array(UserSchema)
}),
z.object({
status: z.literal('error'),
message: z.string(),
code: z.number()
})
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
async function fetchUsers(): Promise<ApiResponse> {
const raw = await fetch('/api/users').then(r => r.json());
return ApiResponseSchema.parse(raw); // Validates AND types
}
Mapped Types: Transforming Types Systematically
Mapped types let you create new types by transforming every property of an existing type.
// Make all properties optional
type Partial<T> = { [K in keyof T]?: T[K] };
// Make all properties required
type Required<T> = { [K in keyof T]-?: T[K] };
// Make all properties readonly
type Readonly<T> = { readonly [K in keyof T]: T[K] };
// Custom: make all properties nullable
type Nullable<T> = { [K in keyof T]: T[K] | null };
// Custom: prefix all keys
type Prefixed<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K]
};
interface User {
name: string;
email: string;
age: number;
}
type PrefixedUser = Prefixed<User, 'user'>;
// Result: { userName: string; userEmail: string; userAge: number }
Utility Types Deep Dive
TypeScript ships with several utility types built on mapped types:
interface Config {
apiUrl: string;
timeout: number;
retries: number;
debug: boolean;
logLevel: 'info' | 'warn' | 'error';
}
// Pick: select specific properties
type ApiConfig = Pick<Config, 'apiUrl' | 'timeout' | 'retries'>;
// Omit: remove specific properties
type PublicConfig = Omit<Config, 'debug' | 'logLevel'>;
// Record: create an object type with specific keys and value type
type FeatureFlags = Record<'darkMode' | 'newDashboard' | 'betaSearch', boolean>;
// Extract: filter union types
type NumOrStr = Extract<string | number | boolean | null, string | number>;
// Result: string | number
// Exclude: remove from union types
type WithoutNull = Exclude<string | number | null | undefined, null | undefined>;
// Result: string | number
Type-Safe Event Emitter
Combining generics with mapped types creates a type-safe event system:
type EventMap = {
'user:login': { userId: string; timestamp: Date };
'user:logout': { userId: string };
'post:created': { postId: string; authorId: string; title: string };
'post:deleted': { postId: string };
'error': { code: number; message: string };
};
class TypedEventEmitter<T extends Record<string, any>> {
private listeners = new Map<keyof T, Set<Function>>();
on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
off<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
this.listeners.get(event)?.delete(callback);
}
}
const events = new TypedEventEmitter<EventMap>();
// Fully typed — callback parameter is inferred
events.on('user:login', (data) => {
console.log(data.userId); // OK: string
console.log(data.timestamp); // OK: Date
});
// Type-safe emit — must provide correct data shape
events.emit('post:created', {
postId: 'p1',
authorId: 'u1',
title: 'Hello World'
});
// Compile error: missing 'title' property
// events.emit('post:created', { postId: 'p1', authorId: 'u1' });
// Compile error: unknown event
// events.on('user:signup', () => {});
Every event name, callback parameter, and emit payload is fully typed. You cannot listen for an event that does not exist, and you cannot emit an event with the wrong data shape.
Generic Constraints: Narrowing the Possible
Generic constraints limit what types a generic parameter can accept:
// Ensure T has a specific property
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
// Ensure T has a length property
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest('hello', 'world'); // OK: strings have length
longest([1, 2], [1, 2, 3]); // OK: arrays have length
// longest(10, 20); // Error: number has no length property
// Ensure T is a constructor
function createInstance<T>(
Constructor: new (...args: any[]) => T,
...args: any[]
): T {
return new Constructor(...args);
}
// Constrain to objects with specific methods
interface Serializable {
serialize(): string;
}
function saveToStorage<T extends Serializable>(item: T): void {
localStorage.setItem('data', item.serialize());
}
Putting It All Together
Here is a real-world example that combines multiple patterns — a type-safe API client:
import { z } from 'zod';
// Define API endpoints with Zod schemas
const endpoints = {
'GET /users': {
response: z.array(UserSchema),
},
'GET /users/:id': {
response: UserSchema,
},
'POST /users': {
body: z.object({ name: z.string(), email: z.string().email() }),
response: UserSchema,
},
'DELETE /users/:id': {
response: z.object({ success: z.boolean() }),
},
} as const;
type Endpoints = typeof endpoints;
type ApiClient = {
[K in keyof Endpoints]: Endpoints[K] extends { body: z.ZodType<infer B> }
? (body: B) => Promise<z.infer<Endpoints[K]['response']>>
: () => Promise<z.infer<Endpoints[K]['response']>>;
};
Every request body is validated, every response is typed, and the compiler ensures you call each endpoint correctly. This is the kind of type safety that makes TypeScript worth the investment.
The type system is a tool. A sharp, powerful, occasionally frustrating tool. But once you learn to wield it properly, it catches bugs before they reach your users, documents your intent better than comments ever could, and makes refactoring feel safe instead of terrifying. That tradeoff — more thinking at write time for fewer bugs at runtime — is one I will take every single time.
Advertisement
Advertisement
Ad Space
Priya Patel
Senior Tech Writer
Covers AI, machine learning, and emerging technologies. Previously at TechCrunch India.
Comments (0)
Leave a Comment
Related Articles
API Design Best Practices: REST, GraphQL, and the Patterns That Scale
A practical guide to designing APIs that last, covering REST conventions, GraphQL fundamentals, tRPC, authentication patterns, rate limiting, error handling, and testing tools with Node.js examples.
DSA Roadmap for Campus Placements: What Actually Matters in 2026
A realistic DSA preparation roadmap for campus placements in India, covering topic priorities, platform choices, language selection, company-wise patterns, and time management strategies.
Redis Caching in Practice: Speed Up Your App Without the Headaches
A practical walkthrough of Redis caching strategies, data structures, cache invalidation, and integration with Node.js, Next.js, and serverless platforms like Upstash.