Overview
TypeScript's type system is one of the most expressive in mainstream languages. Beyond basic annotations, the type-level programming features — conditional types, mapped types, template literals — let you enforce invariants at compile time that would otherwise require runtime checks. The goal: move bugs from production to the IDE.
Architecture
TYPE HIERARCHY
════════════════════════════════════════════════════════
unknown
│
┌──────────┴──────────┐
any (all types)
│ string│number│boolean│...
(unsafe escape) object
│
never (bottom)
unknown: safe top type — must narrow before use
any: unsafe escape hatch — disables type checking
never: unreachable code, exhaustive checks
CONDITIONAL TYPE DISTRIBUTION
════════════════════════════════════════════════════════
type IsString<T> = T extends string ? 'yes' : 'no'
IsString<string> → 'yes'
IsString<number> → 'no'
IsString<string | number> → 'yes' | 'no' ← distributes over union!
To prevent distribution, wrap in tuple:
type IsString<T> = [T] extends [string] ? 'yes' : 'no'
IsString<string | number> → 'no' (whole union vs string)
MAPPED TYPE MODIFIERS
════════════════════════════════════════════════════════
type Partial<T> = { [K in keyof T]?: T[K] } // add ?
type Required<T> = { [K in keyof T]-?: T[K] } // remove ?
type Readonly<T> = { readonly [K in keyof T]: T[K] }
type Mutable<T> = { -readonly [K in keyof T]: T[K] } // remove readonly
Remapping with as:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
// Getters<{name: string}> → { getName: () => string }
Technical Implementation
Deep Readonly with Recursive Conditional Types
// Built-in Readonly<T> is shallow — nested objects are still mutable.
// Deep version uses recursive conditional types.
type DeepReadonly<T> = T extends (infer U)[]
? ReadonlyArray<DeepReadonly<U>>
: T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
server: { port: number; host: string };
db: { url: string; pool: { min: number; max: number } };
}
const config: DeepReadonly<Config> = {
server: { port: 3000, host: 'localhost' },
db: { url: 'postgres://...', pool: { min: 2, max: 10 } },
};
config.server.port = 8080; // Error: readonly
config.db.pool.max = 20; // Error: readonly (nested!)
Type-Safe Event Emitter with Discriminated Unions
// Each event type has a discriminant + typed payload — no casting needed
type AppEvent =
| { type: 'user:login'; payload: { userId: string; ip: string } }
| { type: 'order:placed'; payload: { orderId: string; total: number } }
| { type: 'error:thrown'; payload: { error: Error; context: string } };
// Extract payload type for a given event type
type PayloadOf<E extends AppEvent, T extends E['type']> =
Extract<E, { type: T }>['payload'];
class EventEmitter<E extends { type: string; payload: unknown }> {
private handlers = new Map<string, Set<(payload: any) => void>>();
on<T extends E['type']>(
type: T,
handler: (payload: PayloadOf<Extract<E, { type: T }>, T>) => void
): () => void {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(handler);
return () => this.handlers.get(type)?.delete(handler); // unsubscribe
}
emit<T extends E['type']>(type: T, payload: PayloadOf<Extract<E, { type: T }>, T>): void {
this.handlers.get(type)?.forEach(h => h(payload));
}
}
const bus = new EventEmitter<AppEvent>();
bus.on('user:login', ({ userId, ip }) => console.log(`${userId} from ${ip}`));
// bus.on('user:login', ({ orderId }) => {}); // ← compile error: orderId not on login payload
bus.emit('order:placed', { orderId: 'ord-1', total: 99.99 });
Exhaustive Switch with never
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rectangle'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(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:
// If you add a new Shape variant without handling it here,
// TypeScript will error: "Type 'NewShape' is not assignable to type 'never'"
const _exhaustive: never = shape;
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}
Interview Preparation
Q: What is the difference between unknown and any?
Both can hold any value, but they differ in what you can do with them. any disables type checking entirely — you can call methods, access properties, assign to anything without error. It's a type system escape hatch. unknown is the type-safe alternative: you can store anything in unknown, but before you use it you must narrow it (with typeof, instanceof, or a type guard). unknown forces you to acknowledge the uncertainty and handle it explicitly. Prefer unknown for function parameters that accept arbitrary data (JSON parsing results, API responses) and add a type guard to narrow safely.
Q: How do conditional types distribute over unions?
When a generic conditional type T extends U ? X : Y is instantiated with a union type, TypeScript distributes the conditional across each member of the union individually. IsString<string | number> becomes IsString<string> | IsString<number> = 'yes' | 'no'. This is useful for filtering unions: type NonNullable<T> = T extends null | undefined ? never : T works because never members are eliminated from the resulting union. To prevent distribution, wrap in a tuple: [T] extends [U] checks the whole union against U without distributing.
Q: What is the difference between interface and type alias in TypeScript?
Interfaces are open for extension (declaration merging: two interface Foo declarations in the same scope merge into one). Type aliases cannot be merged. Interfaces can only describe object shapes (not unions, primitives, or complex type expressions). Type aliases are more expressive: type Result<T> = T | Error, type ID = string | number. In practice: prefer interface for public API contracts (library authors, module contracts) where extension is desirable. Use type for unions, intersections, utility types, and mapped/conditional type expressions. For component props in React: either works; choose a team convention and be consistent.
Q: How would you implement a type-safe pick function?
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
return keys.reduce((acc, key) => {
acc[key] = obj[key];
return acc;
}, {} as Pick<T, K>);
}
const user = { id: 1, name: 'Avery', email: 'avery@example.com', role: 'admin' };
const public_ = pick(user, ['id', 'name']);
// Type: { id: number; name: string } — email and role are compile-time absent
// pick(user, ['missing']) // ← compile error: 'missing' not in keyof typeof user
K extends keyof T constrains the keys array to only valid keys of T. The return type Pick<T, K> is the exact subset — no extra properties, no missing ones.
Learning Resources
- TypeScript Handbook — official
- Type Challenges — practice problems
- Matt Pocock TypeScript Tips — advanced patterns
- Programming TypeScript — Boris Cherny — O'Reilly deep dive