TypeScript Type Safety Beyond Basics
Advanced TypeScript techniques for bulletproof type safety — branded types, discriminated unions, template literals, and conditional types with practical examples.
You already know string, number, and interface. But TypeScript’s type system goes far deeper — deep enough to catch entire categories of bugs at compile time that would otherwise surface in production. Let’s explore the techniques that separate TypeScript beginners from experts.
Branded Types: Making Primitive Types Meaningful
A string is a string is a string. But is a user ID the same as an email address? TypeScript says yes. Your business logic says no.
// The problem: nothing prevents mixing these up
function sendEmail(userId: string, email: string): void { /* ... */ }
// This compiles fine but is a bug:
sendEmail(userEmail, oderId);
Branded types fix this by creating distinct types from the same primitive:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type OrderId = Brand<string, 'OrderId'>;
// Smart constructors validate and brand
function createUserId(id: string): UserId {
if (!id.startsWith('usr_')) throw new Error('Invalid user ID');
return id as UserId;
}
function createEmail(email: string): Email {
if (!email.includes('@')) throw new Error('Invalid email');
return email as Email;
}
// Now this is a compile error:
function sendEmail(userId: UserId, email: Email): void { /* ... */ }
const id = createUserId('usr_123');
const email = createEmail('[email protected]');
sendEmail(email, id); // ❌ Type error!
sendEmail(id, email); // ✅ Correct
Discriminated Unions: Modeling State Machines
Instead of optional fields and boolean flags, model your states explicitly:
// ❌ Bad: unclear which fields are valid in which state
interface Request {
status: string;
data?: unknown;
error?: Error;
retryCount?: number;
}
// ✅ Good: discriminated union makes invalid states unrepresentable
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; retryCount: number };
function renderRequest<T>(state: RequestState<T>): string {
switch (state.status) {
case 'idle':
return 'Ready';
case 'loading':
return 'Loading...';
case 'success':
return `Data: ${JSON.stringify(state.data)}`; // TypeScript knows `data` exists
case 'error':
return `Error: ${state.error.message} (retries: ${state.retryCount})`;
}
}
TypeScript narrows the type in each branch. You can’t access data in the error case or error in the success case.
Exhaustiveness Checking
Ensure you handle every case:
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${JSON.stringify(value)}`);
}
function handleState(state: RequestState<unknown>): void {
switch (state.status) {
case 'idle': return;
case 'loading': return;
case 'success': return;
// If you forget 'error', TypeScript complains:
// case 'error': return;
default:
assertNever(state); // ❌ Error: type { status: 'error'; ... } is not assignable to never
}
}
Template Literal Types: Type-Safe Strings
TypeScript can enforce string patterns at the type level:
type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type APIVersion = 'v1' | 'v2';
type Resource = 'users' | 'orders' | 'products';
// Only valid API routes
type APIRoute = `/${APIVersion}/${Resource}`;
// Result: "/v1/users" | "/v1/orders" | "/v1/products" | "/v2/users" | "/v2/orders" | "/v2/products"
function callAPI(method: HTTPMethod, route: APIRoute): Promise<Response> {
return fetch(`https://api.example.com${route}`, { method });
}
callAPI('GET', '/v1/users'); // ✅
callAPI('POST', '/v3/users'); // ❌ Type error
callAPI('GET', '/v1/categories'); // ❌ Type error
Parsing Strings with Template Literals
type EventName = `${string}:${string}`;
type ParseEvent<T extends string> =
T extends `${infer Namespace}:${infer Action}`
? { namespace: Namespace; action: Action }
: never;
type Result = ParseEvent<'user:created'>;
// { namespace: 'user'; action: 'created' }
// Type-safe event emitter
class TypedEmitter<Events extends Record<string, unknown>> {
private handlers = new Map<string, Set<Function>>();
on<K extends keyof Events & string>(
event: K,
handler: (payload: Events[K]) => void,
): void {
const set = this.handlers.get(event) ?? new Set();
set.add(handler);
this.handlers.set(event, set);
}
emit<K extends keyof Events & string>(event: K, payload: Events[K]): void {
this.handlers.get(event)?.forEach((fn) => fn(payload));
}
}
// Usage
interface AppEvents {
'user:created': { id: string; name: string };
'user:deleted': { id: string };
'order:placed': { orderId: string; total: number };
}
const emitter = new TypedEmitter<AppEvents>();
emitter.on('user:created', (payload) => {
console.log(payload.name); // ✅ TypeScript knows the shape
});
emitter.emit('user:created', { id: '1' }); // ❌ Missing 'name'
Conditional Types: Type-Level Logic
Conditional types let you express “if-then-else” at the type level:
// Extract the return type of async functions
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number
// Make specific fields optional
type MakeOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface User {
id: string;
name: string;
email: string;
avatar: string;
}
type CreateUserDTO = MakeOptional<User, 'id' | 'avatar'>;
// { name: string; email: string; id?: string; avatar?: string }
Recursive Conditional Types
// Deep readonly — makes every nested property readonly
type DeepReadonly<T> = T extends (infer U)[]
? readonly DeepReadonly<U>[]
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
database: {
host: string;
port: number;
credentials: {
user: string;
password: string;
};
};
features: string[];
}
type ReadonlyConfig = DeepReadonly<Config>;
// Every nested field is now readonly — mutations are compile errors
The satisfies Operator
Introduced in TypeScript 4.9, satisfies validates a value matches a type while preserving its literal type:
type ColorMap = Record<string, [number, number, number] | string>;
// With `satisfies`, TypeScript validates AND preserves literal types
const palette = {
red: [255, 0, 0],
green: '#00ff00',
blue: [0, 0, 255],
} satisfies ColorMap;
// TypeScript knows these specific types:
palette.red.map((c) => c * 2); // ✅ It knows red is a tuple
palette.green.toUpperCase(); // ✅ It knows green is a string
// But still validates:
const bad = {
red: [255, 0], // ❌ Error: wrong tuple length
green: '#00ff00',
} satisfies ColorMap;
const Assertions and Narrowing
// Without as const
const routes = {
home: '/',
about: '/about',
users: '/users',
};
// Type: { home: string; about: string; users: string }
// With as const
const routes2 = {
home: '/',
about: '/about',
users: '/users',
} as const;
// Type: { readonly home: "/"; readonly about: "/about"; readonly users: "/users" }
// Now you can derive types from values:
type Route = (typeof routes2)[keyof typeof routes2];
// "/" | "/about" | "/users"
Practical Pattern: Type-Safe API Client
Combining several techniques into a real-world pattern:
interface APIEndpoints {
'GET /users': { response: User[]; params: { page?: number } };
'GET /users/:id': { response: User; params: { id: string } };
'POST /users': { response: User; body: CreateUserDTO };
'DELETE /users/:id': { response: void; params: { id: string } };
}
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ExtractMethod<T extends string> = T extends `${infer M} ${string}` ? M : never;
type ExtractPath<T extends string> = T extends `${string} ${infer P}` ? P : never;
async function apiCall<K extends keyof APIEndpoints>(
endpoint: K,
options?: Omit<APIEndpoints[K], 'response'>,
): Promise<APIEndpoints[K]['response']> {
const [method, path] = (endpoint as string).split(' ');
// Implementation...
return {} as any;
}
// Fully type-safe API calls:
const users = await apiCall('GET /users', { params: { page: 1 } });
// ^? User[]
const user = await apiCall('GET /users/:id', { params: { id: '123' } });
// ^? User
await apiCall('POST /users', { body: { name: 'Jane', email: '[email protected]' } });
Key Takeaways
- Branded types prevent mixing up primitives that represent different concepts
- Discriminated unions make invalid states unrepresentable
- Template literal types enforce string patterns at compile time
- Conditional types let you write type-level logic
satisfiesvalidates types while preserving specificity- Combine techniques to build type-safe APIs that catch bugs before runtime
The investment in learning advanced TypeScript types pays off every day — in fewer runtime errors, better IDE autocomplete, and code that documents itself through types.