Generics & Conditional Types
Generics are TypeScript's abstraction mechanism at the type level. Conditional types add branching logic. Together they let you write types that are computed, not just declared â and that's what separates a senior engineer's TypeScript from everyone else's.
Generic Functions â Type Inference and Constraints
A generic function defers a type decision to the call site:
function identity<T>(value: T): T {
return value;
}
const a = identity("hello"); // T inferred as "hello" (literal type)
const b = identity(42); // T inferred as 42Constraints with extends
Constraints restrict what types a generic can accept â this is bounded polymorphism:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Ada", age: 36 };
getProperty(user, "name"); // â
returns string
getProperty(user, "email"); // â Error: "email" is not in keyof typeof userThe constraint K extends keyof T means K must be one of the known keys of T. The return type T[K] is an indexed access type â it resolves to the type of the property at key K.
Generic Defaults
Generics can have defaults, just like function parameters:
interface ApiResponse<T = unknown> {
data: T;
status: number;
timestamp: Date;
}
const response: ApiResponse = { data: {}, status: 200, timestamp: new Date() };
const typed: ApiResponse<User> = { data: user, status: 200, timestamp: new Date() };Generic Interfaces and Classes
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
class InMemoryRepository<T extends { id: string }> implements Repository<T> {
private store = new Map<string, T>();
async findById(id: string) {
return this.store.get(id) ?? null;
}
async findAll() {
return [...this.store.values()];
}
async save(entity: T) {
this.store.set(entity.id, entity);
return entity;
}
async delete(id: string) {
this.store.delete(id);
}
}The constraint T extends { id: string } ensures every entity has an id â the repository can safely use entity.id without further narrowing.
Conditional Types
Conditional types introduce branching at the type level:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // falseThe infer Keyword
infer declares a type variable inside a conditional type â it extracts a type from a pattern:
type ReturnOf<T> = T extends (...args: any[]) => infer R ? R : never;
type A = ReturnOf<() => string>; // string
type B = ReturnOf<(x: number) => void>; // void
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type C = UnpackPromise<Promise<string>>; // string
type D = UnpackPromise<number>; // number (passthrough)infer is how TypeScript's built-in ReturnType, Parameters, and Awaited are implemented.
Nested infer
You can nest infer to extract deeply:
type FirstArg<T> = T extends (first: infer F, ...rest: any[]) => any ? F : never;
type Arg = FirstArg<(name: string, age: number) => void>; // string
type UnpackArray<T> = T extends Array<infer U>
? U extends Promise<infer V>
? V
: U
: T;
type E = UnpackArray<Promise<string>[]>; // stringDistributive Conditional Types
When a conditional type acts on a naked type parameter that receives a union, it distributes â applying the condition to each member individually:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Distributes: ToArray<string> | ToArray<number>
// = string[] | number[]
// NOT (string | number)[]Preventing Distribution
Wrap both sides in a tuple to prevent distribution:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type Result2 = ToArrayNonDist<string | number>;
// = (string | number)[] â no distributionThis is critical for utility types that should treat a union as a single unit rather than spreading over it.
Mapped Types
Mapped types transform every property in a type:
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Optional<T> = { [K in keyof T]?: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };Key Remapping with as
TypeScript 4.1 added key remapping â transform the keys themselves:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
}
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }Filtering Keys
Use as with never to filter out keys:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringFields = OnlyStrings<{ name: string; age: number; email: string }>;
// { name: string; email: string }Template Literal Types
String manipulation at the type level:
type EventName = `on${Capitalize<"click" | "scroll" | "resize">}`;
// "onClick" | "onScroll" | "onResize"
type HTTPMethod = "GET" | "POST" | "PUT" | "DELETE";
type Endpoint = "/users" | "/posts";
type APIRoute = `${HTTPMethod} ${Endpoint}`;
// "GET /users" | "GET /posts" | "POST /users" | ... (8 total combinations)Template literals distribute over unions, creating every combination â powerful for expressing string-based APIs at the type level.
Recursive Conditional Types
Types can reference themselves â enabling operations on arbitrary-depth structures:
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
type Nested = {
a: { b: { c: string } };
d: number[];
};
type Frozen = DeepReadonly<Nested>;
// { readonly a: { readonly b: { readonly c: string } }; readonly d: readonly number[] }Type-Level Arithmetic (Advanced)
You can even do arithmetic with tuple lengths:
type BuildTuple<N extends number, T extends unknown[] = []> =
T["length"] extends N ? T : BuildTuple<N, [...T, unknown]>;
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]["length"];
type Sum = Add<3, 4>; // 7This hits recursion limits quickly, but demonstrates the Turing-complete nature of TypeScript's type system.
Real-World Example: Type-Safe Event Emitter
type EventMap = {
userLogin: { userId: string; timestamp: number };
pageView: { path: string; referrer: string | null };
error: { code: number; message: string };
};
class TypedEmitter<Events extends Record<string, unknown>> {
private listeners = new Map<string, Set<Function>>();
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
if (!this.listeners.has(event as string)) {
this.listeners.set(event as string, new Set());
}
this.listeners.get(event as string)!.add(handler);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners.get(event as string)?.forEach(fn => fn(payload));
}
}
const emitter = new TypedEmitter<EventMap>();
emitter.on("userLogin", (data) => {
console.log(data.userId); // â
autocomplete works
console.log(data.path); // â Error: 'path' doesn't exist on userLogin
});
emitter.emit("error", { code: 500, message: "fail" }); // â
emitter.emit("error", { code: "500" }); // â code must be numberReal-World Example: Type-Safe API Client
interface APIEndpoints {
"GET /users": { response: User[]; query: { limit?: number } };
"GET /users/:id": { response: User; params: { id: string } };
"POST /users": { response: User; body: CreateUserDTO };
"PUT /users/:id": { response: User; params: { id: string }; body: UpdateUserDTO };
}
type ExtractMethod<T extends string> = T extends `${infer M} ${string}` ? M : never;
type ExtractPath<T extends string> = T extends `${string} ${infer P}` ? P : never;
type EndpointConfig<T extends keyof APIEndpoints> = APIEndpoints[T];
async function apiCall<E extends keyof APIEndpoints>(
endpoint: E,
config: Omit<EndpointConfig<E>, "response">
): Promise<EndpointConfig<E>["response"]> {
// Implementation would parse method, path, and config
throw new Error("Not implemented");
}
// Usage â fully typed request and response
const users = await apiCall("GET /users", { query: { limit: 10 } });
// ^? User[]
const user = await apiCall("POST /users", { body: { name: "Ada", email: "ada@test.com" } });
// ^? UserReal-World Example: Builder Pattern with Type Narrowing
interface QueryBuilder<
HasSelect extends boolean = false,
HasFrom extends boolean = false,
HasWhere extends boolean = false,
> {
select(fields: string[]): QueryBuilder<true, HasFrom, HasWhere>;
from(table: string): QueryBuilder<HasSelect, true, HasWhere>;
where(condition: string): QueryBuilder<HasSelect, HasFrom, true>;
execute: HasSelect extends true
? HasFrom extends true
? () => Promise<unknown[]>
: never
: never;
}
function createQuery(): QueryBuilder {
// implementation
return {} as QueryBuilder;
}
const query = createQuery()
.select(["name", "email"])
.from("users")
.where("active = true");
query.execute(); // â
â only available after both select() and from()
const incomplete = createQuery().select(["name"]);
incomplete.execute; // â â type is `never`, cannot callThe builder tracks which methods have been called through type parameters, making invalid states unrepresentable.
Interview Power Moves
-
"Generics are parametric polymorphism â they let you write code that's generic over types, not just values" â shows you understand the PL theory behind the feature.
-
"Distributive conditional types spread over unions by default â wrap in a tuple to prevent it" â this catches many experienced engineers off guard.
-
"I use
inferto extract types from existing structures rather than declaring parallel type hierarchies" â shows you avoid duplication at the type level. -
"Mapped types with key remapping replaced 90% of the cases where we used to need code generation" â practical insight about real codebases.
-
"TypeScript's type system is Turing-complete, which means you can express almost anything â but you shouldn't. I optimize for readability at the type level just like at the value level."