Generics Deep Dive
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.
1. Why Generics?
Consider this function:2. Generic Functions
Basic Syntax
Multiple Type Parameters
Generic Arrow Functions
3. Generic Constraints
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.”
extends Keyword
keyof Constraint
Multiple Constraints
Conditional Constraints
4. Generic Classes
Classes that work with multiple types. Generic classes are the backbone of reusable data structures and service layers in TypeScript. Instead of writingNumberStack, StringStack, and UserStack as separate classes, you write Stack<T> once and let the consumer specify the type.
Generic Stack
Generic with Static Members
5. Generic Interfaces
Implementing Generic Interfaces
6. Default Type Parameters
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.7. Generic Type Aliases
8. Generic Utility Patterns
Factory Pattern
Builder Pattern
Result/Either Pattern
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.Option/Maybe Pattern
9. Advanced Generic Patterns
Variadic Tuple Types
Recursive Generics
Conditional Generics
10. Real-World Examples
Type-Safe Event Emitter
Type-Safe HTTP Client
Summary
| Concept | Example |
|---|---|
| Basic Generic | function identity<T>(val: T): T |
| Multiple Parameters | function pair<T, U>(a: T, b: U): [T, U] |
| Constraints | <T extends { length: number }> |
| keyof Constraint | <K extends keyof T> |
| Generic Class | class Stack<T> { items: T[] } |
| Generic Interface | interface Repo<T> { find(id): T } |
| Default Parameter | interface Response<T = any> |
| Conditional | T extends U ? X : Y |
| infer | T extends Array<infer U> ? U : T |
| Variadic Tuples | [...T, ...U] |
Interview Deep-Dive
Q: A junior developer asks you 'why can't I just use any instead of generics?' Walk me through the argument you would make.
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.
anydestroys type information:function identity(value: any): anyaccepts anything and returnsany. The caller gets no information back.const result = identity('hello')—resultisany, so TypeScript cannot tell you it is a string. You can callresult.nonExistentMethod()and TypeScript will not warn you.- Generics preserve type information:
function identity<T>(value: T): T— when you callidentity('hello'), TypeScript infersT = string, andresultisstring. Autocomplete works, method calls are checked, and type errors are caught. - Real-world example: A
useStatehook.const [count, setCount] = useState(0)— with generics,countisnumberandsetCountonly acceptsnumber. Withany, 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.
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.
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 asT. If you call the function withnumber, 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 : Ymeans “check at the type level whether T is assignable to string.” It does not restrictT— 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,extendsappears twice with different meanings. The first constrainsTtostring | number. The second conditionally selects the return type based on whatTactually is. - Practical example:
<T extends Printable>is like a function parameter type — it says what you can pass in.T extends Printable ? ... : ...is like anifstatement — it checks what was passed and branches accordingly.
Q: Walk me through the Result/Either pattern in TypeScript generics. Why is it better than throwing exceptions for expected errors?
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 whereokis 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. Withfunction parseJson(input: string): Result<Config, ParseError>, the return type explicitly communicates failure possibility. The compiler forces you to checkresult.okbefore accessingresult.value. - Composability: Results compose with
mapandflatMapoperations. 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
neverthrowprovide full implementations.
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?
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
bodyandresponsetypes. 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 constrainsPto valid paths andMto valid methods for that path. - Step 3: Path parameter extraction: Using template literal types to extract
:idfrom/users/:idand require{ id: string }in the options. - What the caller sees:
client.request('GET', '/users')returnsPromise<User[]>.client.request('POST', '/users', { body: { name: 'Alice' } })— TypeScript checks the body. Passing an invalid route or method is a compile error.
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.