Generics are the cornerstone of reusable, type-safe code in TypeScript. They allow you to write code that works with any type while maintaining full type information. Think of generics as “fill-in-the-blank” types — you write a function or class with placeholder slots (T, U), and the caller fills in the blanks with concrete types when they use it. It is the same concept as a template in a word processor: the structure is fixed, but the content is customizable.
// Without generics - loses type informationfunction identity(value: any): any { return value;}const result = identity('hello'); // type is 'any' - not helpful!
With generics:
// With generics - type is preservedfunction identity<T>(value: T): T { return value;}const result = identity('hello'); // type is 'string'const num = identity(42); // type is 'number'
// Arrow function with genericsconst identity = <T>(value: T): T => value;// In TSX files, add a trailing comma to avoid JSX ambiguityconst identity2 = <T,>(value: T): T => value;// With constraintconst getLength = <T extends { length: number }>(item: T): number => { return item.length;};
Restrict what types can be used with generics. Unconstrained generics accept anything, but sometimes your function needs to access specific properties (like .length or .id). Constraints let you say “T can be any type, but it must have at least these properties.” Think of it as a job requirement: “any applicant is welcome, but they must have a driver’s license.”
type NumberOrString<T> = T extends number ? number : string;function process<T extends number | string>(value: T): NumberOrString<T> { if (typeof value === 'number') { return (value * 2) as NumberOrString<T>; } return value.toUpperCase() as NumberOrString<T>;}const num = process(42); // number (84)const str = process('hello'); // string ('HELLO')
Classes that work with multiple types. Generic classes are the backbone of reusable data structures and service layers in TypeScript. Instead of writing NumberStack, StringStack, and UserStack as separate classes, you write Stack<T> once and let the consumer specify the type.
// Container<T> works with any type. The caller fills in T when creating an instance.class Container<T> { private value: T; constructor(value: T) { this.value = value; } getValue(): T { return this.value; } setValue(value: T): void { this.value = value; }}const stringContainer = new Container('hello');console.log(stringContainer.getValue()); // 'hello' (string)const numberContainer = new Container(42);console.log(numberContainer.getValue()); // 42 (number)
Provide defaults for generic parameters. Just like default function parameters, you can give type parameters a fallback value. This makes generics more ergonomic — callers who do not care about the specific type can skip the angle brackets entirely.
// Default type parameter -- if no T is provided, it defaults to 'any'// This means Response can be used as Response<User> or just Responseinterface Response<T = any> { data: T; status: number; message: string;}const response1: Response = { data: 'anything', status: 200, message: 'OK' };const response2: Response<User> = { data: user, status: 200, message: 'OK' };// Multiple defaultsinterface Paginated<T = any, M = Record<string, unknown>> { items: T[]; total: number; page: number; metadata: M;}// Defaults with constraintsinterface Container<T extends object = object> { value: T;}
interface Constructor<T> { new (...args: any[]): T;}function createInstance<T>(ctor: Constructor<T>, ...args: any[]): T { return new ctor(...args);}class User { constructor(public name: string) {}}const user = createInstance(User, 'Alice'); // User
This is one of the most impactful patterns in TypeScript. Instead of throwing exceptions (which bypass the type system), you return a union that forces the caller to handle both success and failure. It makes error handling explicit, composable, and type-safe.
// The Result type is a discriminated union: 'ok' is the tag field.// Default error type is Error, but callers can customize it.type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E };function divide(a: number, b: number): Result<number, string> { if (b === 0) { return { ok: false, error: 'Division by zero' }; } return { ok: true, value: a / b };}const result = divide(10, 2);if (result.ok) { // TypeScript narrows: result is { ok: true; value: number } console.log(result.value); // 5} else { // TypeScript narrows: result is { ok: false; error: string } console.error(result.error);}// The caller CANNOT access result.value without checking result.ok first.// This eliminates "forgot to handle the error" bugs at compile time.
Q: A junior developer asks you 'why can't I just use any instead of generics?' Walk me through the argument you would make.
Strong Answer:This is one of the most important questions to get right because it reveals whether you understand the core value proposition of TypeScript’s type system.
any destroys type information: function identity(value: any): any accepts anything and returns any. The caller gets no information back. const result = identity('hello') — result is any, so TypeScript cannot tell you it is a string. You can call result.nonExistentMethod() and TypeScript will not warn you.
Generics preserve type information: function identity<T>(value: T): T — when you call identity('hello'), TypeScript infers T = string, and result is string. Autocomplete works, method calls are checked, and type errors are caught.
Real-world example: A useState hook. const [count, setCount] = useState(0) — with generics, count is number and setCount only accepts number. With any, both are unchecked. The generic version preserves the relationship between the state value and the setter.
The compile-time cost argument: You write a generic function once and every caller benefits from type safety automatically. With any, you push the burden to every call site — every caller must remember the correct types and cast manually.
Follow-up: Are there cases where ‘any’ is actually the right choice over generics?Rarely, but yes. When you are writing a low-level utility that genuinely operates on any value without caring about its type — like a deep clone function using JSON.parse(JSON.stringify(x)). The input and output types are unrelated, so a generic T would be misleading. Also during incremental migration from JavaScript, any is a pragmatic stepping stone. But in application code, if you reach for any, it is almost always a sign that you should be using a generic, a union, or unknown.
Q: Explain the difference between 'T extends SomeType' as a constraint versus as a conditional type. They look similar but do completely different things.
Strong Answer:This is a subtle but critical distinction because the extends keyword is overloaded in TypeScript.
As a constraint (in angle brackets): <T extends { length: number }> means “T must satisfy this shape.” It restricts which types can be used as T. If you call the function with number, the compiler rejects it. The constraint narrows the set of valid type arguments. It is a prerequisite, not a condition.
As a conditional (in ternary): T extends string ? X : Y means “check at the type level whether T is assignable to string.” It does not restrict T — it branches. Both branches produce valid output. It is pattern matching.
Where confusion happens: function process<T extends string | number>(value: T): T extends string ? string : number. Here, extends appears twice with different meanings. The first constrains T to string | number. The second conditionally selects the return type based on what T actually is.
Practical example: <T extends Printable> is like a function parameter type — it says what you can pass in. T extends Printable ? ... : ... is like an if statement — it checks what was passed and branches accordingly.
Follow-up: Can you use conditional return types with overloads, and which is better?Yes, they solve the same problem. Conditional return types are more concise and work with generics. Overloads are more explicit with separate signatures. My preference is conditional types for 2 variants and overloads for 3+ variants, because conditional types with many branches become hard to read. The key difference is that conditional return types work with generic inference, while overloads require exact signature matching.
Q: Walk me through the Result/Either pattern in TypeScript generics. Why is it better than throwing exceptions for expected errors?
Strong Answer:The Result pattern is one of the most impactful patterns you can adopt in a TypeScript codebase.
The type: type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E }. A discriminated union where ok is the tag.
Why it is better than exceptions: Exceptions are invisible in TypeScript’s type system. function parseJson(input: string): Config — the type signature says nothing about the fact that it can fail. With function parseJson(input: string): Result<Config, ParseError>, the return type explicitly communicates failure possibility. The compiler forces you to check result.ok before accessing result.value.
Composability: Results compose with map and flatMap operations. This leads to flatter, more readable code than nested try/catch blocks.
When to use exceptions instead: For truly unexpected errors — programmer mistakes, invariant violations, out-of-memory. The rule: if the caller is expected to handle the error as part of normal business logic (validation failure, record not found), use Result. If the error indicates a bug, throw.
Real-world adoption: This pattern is standard in Rust, Go (multiple return values), and increasingly in TypeScript. Libraries like neverthrow provide full implementations.
Follow-up: How does the Result pattern interact with async/await?The function returns Promise<Result<T, E>> instead of Promise<T>. The caller does const result = await fetchUser(id); if (!result.ok) return result; — early return propagates the error. Libraries like neverthrow provide ResultAsync<T, E> which wraps Promise and Result together, enabling .map().mapErr() chaining without intermediate awaits.
Q: How would you build a type-safe HTTP client using generics where the route, method, request body, and response type are all statically checked?
Strong Answer:This is one of the most impressive TypeScript patterns because it combines multiple advanced features into a practical API.
Step 1: Define the API contract as a type: Each route is a key, each HTTP method is a nested key, and each method defines body and response types. This is the single source of truth for your API shape.
Step 2: Generic request method: async request<P extends keyof Endpoints, M extends keyof Endpoints[P]>(method: M, path: P, options?: ...): Promise<Endpoints[P][M]['response']>. TypeScript constrains P to valid paths and M to valid methods for that path.
Step 3: Path parameter extraction: Using template literal types to extract :id from /users/:id and require { id: string } in the options.
What the caller sees: client.request('GET', '/users') returns Promise<User[]>. client.request('POST', '/users', { body: { name: 'Alice' } }) — TypeScript checks the body. Passing an invalid route or method is a compile error.
This pattern is used in production by libraries like zodios and internal API clients at companies like Stripe.Follow-up: What is the limitation of this approach, and how would you handle API versioning?The main limitation is that the type definition must be maintained in sync with the actual API. If the backend changes and the TypeScript definition is not updated, the types lie. The solution is code generation: tools like OpenAPI generators produce TypeScript types directly from the API schema. For API versioning, define separate endpoint maps per version and create version-specific client instances.