TypeScript has become the default choice for serious JavaScript development. With over 43% of developers using it (Stack Overflow 2025), its type system catches entire categories of bugs at compile time. But TypeScript’s power goes far beyond basic annotations — its advanced type system is a programming language in its own right. Here are techniques that separate proficient developers from beginners.
Generics: Write Once, Type Everything
// Generic API response wrapper — type-safe for any data shape
interface ApiResponse<T> {
status: number;
data: T;
timestamp: string;
}
function handleResponse<T>(response: ApiResponse<T>): T {
if (response.status >= 400) throw new Error(`API error: ${response.status}`);
return response.data;
}
interface User { id: string; name: string; email: string; }
const userResp: ApiResponse<User> = { status: 200, data: { id: '1', name: 'Alice', email: 'alice@example.com' }, timestamp: new Date().toISOString() };
const user = handleResponse(userResp);
// user is typed as User — full autocomplete, full safety
// Constrained generic — T must have an 'id' property
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
Discriminated Unions
By adding a literal type field, you get exhaustive pattern matching that eliminates impossible states:
type FetchState<T> =
| { type: 'idle' }
| { type: 'loading'; startedAt: number }
| { type: 'success'; data: T; fetchedAt: number }
| { type: 'error'; message: string; retryCount: number };
function renderState<T>(state: FetchState<T>): string {
switch (state.type) {
case 'idle': return 'Ready';
case 'loading': return `Loading... (${Date.now() - state.startedAt}ms)`;
case 'success': return `Got: ${JSON.stringify(state.data)}`;
case 'error': return `Error: ${state.message} (retry ${state.retryCount}/3)`;
// Remove a case → compile error. Impossible to forget a state.
}
}
Utility Types You Should Know
interface User { id: string; name: string; email: string; role: 'admin'|'editor'|'viewer'; createdAt: Date; }
type UserUpdate = Partial<Omit<User, 'id' | 'createdAt'>>; // All fields optional except id/createdAt
type UserSummary = Pick<User, 'id' | 'name' | 'role'>; // Just id, name, role
type UserMap = Record<string, User>; // Typed lookup table
type NotAdmin = Exclude<User['role'], 'admin'>; // 'editor' | 'viewer'
// ReturnType extracts a function's return type
function createUser(name: string) { return { id: crypto.randomUUID(), name, createdAt: new Date() }; }
type CreatedUser = ReturnType<typeof createUser>;
Template Literal Types
type Entity = 'user' | 'order' | 'product';
type Action = 'created' | 'updated' | 'deleted';
type EventName = `${Entity}:${Action}`;
// 'user:created' | 'user:updated' | ... (9 combinations, all type-safe)
The satisfies Operator
Validates that a value matches a type WITHOUT widening its inferred type — best of both worlds:
type Theme = Record<string, string | number>;
const theme = {
primary: '#6366f1',
fontSize: 16,
} satisfies Theme;
theme.primary; // Type: '#6366f1' (not string!)
theme.fontSize; // Type: 16 (not number!)
Mapped & Conditional Types
// Make all string properties nullable
type Nullable<T> = { [K in keyof T]: T[K] extends string ? T[K] | null : T[K]; };
// Deep readonly — recursively freeze nested objects
type DeepReadonly<T> = { readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K]; };
These patterns compound. Once you internalize generics, discriminated unions, utility types, and satisfies, you write code that’s simultaneously more flexible and more type-safe.
Further reading: TypeScript Handbook | SO 2025 Survey

Leave a Reply