Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

TypeScript Generics

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:
// Without generics - loses type information
function identity(value: any): any {
  return value;
}

const result = identity('hello'); // type is 'any' - not helpful!
With generics:
// With generics - type is preserved
function identity<T>(value: T): T {
  return value;
}

const result = identity('hello'); // type is 'string'
const num = identity(42);          // type is 'number'

2. Generic Functions

Basic Syntax

// Type parameter T
function wrap<T>(value: T): T[] {
  return [value];
}

wrap('hello'); // string[]
wrap(42);      // number[]
wrap(true);    // boolean[]

// Explicit type argument
wrap<string>('hello'); // string[]

Multiple Type Parameters

function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

pair('hello', 42);           // [string, number]
pair(true, { name: 'Alice' }); // [boolean, { name: string }]

// Map-like function
function mapPair<T, U, V>(pair: [T, U], fn: (a: T, b: U) => V): V {
  return fn(pair[0], pair[1]);
}

mapPair([1, 2], (a, b) => a + b); // 3 (number)

Generic Arrow Functions

// Arrow function with generics
const identity = <T>(value: T): T => value;

// In TSX files, add a trailing comma to avoid JSX ambiguity
const identity2 = <T,>(value: T): T => value;

// With constraint
const getLength = <T extends { length: number }>(item: T): number => {
  return item.length;
};

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

interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(item: T): number {
  console.log(item.length);
  return item.length;
}

logLength('hello');        // 5 - string has length
logLength([1, 2, 3]);      // 3 - array has length
logLength({ length: 10 }); // 10 - object with length
// logLength(42);          // ❌ Error: number doesn't have length

keyof Constraint

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 25 };

getProperty(user, 'name'); // string
getProperty(user, 'age');  // number
// getProperty(user, 'email'); // ❌ Error: 'email' is not a key

Multiple Constraints

interface Printable {
  print(): void;
}

interface Loggable {
  log(): void;
}

function process<T extends Printable & Loggable>(item: T): void {
  item.print();
  item.log();
}

Conditional Constraints

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')

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 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)

Generic Stack

class Stack<T> {
  private items: T[] = [];

  push(item: T): void {
    this.items.push(item);
  }

  pop(): T | undefined {
    return this.items.pop();
  }

  peek(): T | undefined {
    return this.items[this.items.length - 1];
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }

  size(): number {
    return this.items.length;
  }

  clear(): void {
    this.items = [];
  }
}

const stack = new Stack<number>();
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack.pop()); // 3

Generic with Static Members

class Registry<T> {
  private static instances = new Map<string, any>();
  private items = new Map<string, T>();

  register(key: string, item: T): void {
    this.items.set(key, item);
  }

  get(key: string): T | undefined {
    return this.items.get(key);
  }

  static getInstance<U>(name: string): Registry<U> {
    if (!this.instances.has(name)) {
      this.instances.set(name, new Registry<U>());
    }
    return this.instances.get(name);
  }
}

5. Generic Interfaces

// Generic interface
interface Repository<T> {
  find(id: number): T | undefined;
  findAll(): T[];
  create(item: Omit<T, 'id'>): T;
  update(id: number, item: Partial<T>): T | undefined;
  delete(id: number): boolean;
}

// Generic with multiple parameters
interface Result<T, E> {
  success: boolean;
  data?: T;
  error?: E;
}

// Extending generic interface
interface TimestampedRepository<T> extends Repository<T> {
  findByDate(start: Date, end: Date): T[];
}

Implementing Generic Interfaces

interface Entity {
  id: number;
}

interface User extends Entity {
  name: string;
  email: string;
}

class UserRepository implements Repository<User> {
  private users: User[] = [];
  private nextId = 1;

  find(id: number): User | undefined {
    return this.users.find(u => u.id === id);
  }

  findAll(): User[] {
    return [...this.users];
  }

  create(item: Omit<User, 'id'>): User {
    const user = { ...item, id: this.nextId++ };
    this.users.push(user);
    return user;
  }

  update(id: number, item: Partial<User>): User | undefined {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return undefined;
    this.users[index] = { ...this.users[index], ...item };
    return this.users[index];
  }

  delete(id: number): boolean {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) return false;
    this.users.splice(index, 1);
    return true;
  }
}

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.
// Default type parameter -- if no T is provided, it defaults to 'any'
// This means Response can be used as Response<User> or just Response
interface 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 defaults
interface Paginated<T = any, M = Record<string, unknown>> {
  items: T[];
  total: number;
  page: number;
  metadata: M;
}

// Defaults with constraints
interface Container<T extends object = object> {
  value: T;
}

7. Generic Type Aliases

// Generic type alias -- reusable type "templates" that any code can instantiate
type Nullable<T> = T | null | undefined;

type MaybeString = Nullable<string>; // string | null | undefined

// This pattern is extremely common in React applications for managing async state.
// Instead of writing separate loading/error/data types for every entity, define it once.
type AsyncResult<T> = {
  loading: boolean;
  error: Error | null;
  data: T | null;
};

type UserResult = AsyncResult<User>;    // { loading: boolean; error: Error | null; data: User | null }
type ProductResult = AsyncResult<Product>; // Same shape, different data type

// Recursive generic
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

// Generic function type
type Mapper<T, U> = (item: T) => U;

const toUpper: Mapper<string, string> = (s) => s.toUpperCase();
const toLength: Mapper<string, number> = (s) => s.length;

8. Generic Utility Patterns

Factory Pattern

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

Builder Pattern

class QueryBuilder<T> {
  private query: Partial<T> = {};

  where<K extends keyof T>(key: K, value: T[K]): this {
    this.query[key] = value;
    return this;
  }

  build(): Partial<T> {
    return { ...this.query };
  }
}

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

const query = new QueryBuilder<User>()
  .where('role', 'admin')
  .where('name', 'Alice')
  .build();
// { role: 'admin', name: 'Alice' }

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.
// 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.

Option/Maybe Pattern

class Option<T> {
  private constructor(private value: T | null) {}

  static some<T>(value: T): Option<T> {
    return new Option(value);
  }

  static none<T>(): Option<T> {
    return new Option<T>(null);
  }

  map<U>(fn: (value: T) => U): Option<U> {
    if (this.value === null) return Option.none();
    return Option.some(fn(this.value));
  }

  flatMap<U>(fn: (value: T) => Option<U>): Option<U> {
    if (this.value === null) return Option.none();
    return fn(this.value);
  }

  getOrElse(defaultValue: T): T {
    return this.value ?? defaultValue;
  }

  isSome(): boolean {
    return this.value !== null;
  }
}

function findUser(id: number): Option<User> {
  const user = users.find(u => u.id === id);
  return user ? Option.some(user) : Option.none();
}

const userName = findUser(1)
  .map(user => user.name)
  .getOrElse('Unknown');

9. Advanced Generic Patterns

Variadic Tuple Types

// Concatenate tuples
type Concat<T extends any[], U extends any[]> = [...T, ...U];

type A = Concat<[1, 2], [3, 4]>; // [1, 2, 3, 4]

// Function composition
function pipe<A extends any[], B, C>(
  fn1: (...args: A) => B,
  fn2: (arg: B) => C
): (...args: A) => C {
  return (...args) => fn2(fn1(...args));
}

const addOne = (x: number) => x + 1;
const double = (x: number) => x * 2;
const addOneThenDouble = pipe(addOne, double);

console.log(addOneThenDouble(5)); // 12

Recursive Generics

// Flatten nested arrays
type Flatten<T> = T extends Array<infer U> ? Flatten<U> : T;

type Nested = number[][][];
type Flat = Flatten<Nested>; // number

// Deep object paths
type PathKeys<T, Prefix extends string = ''> = T extends object
  ? {
      [K in keyof T]: K extends string
        ? Prefix extends ''
          ? K | `${K}.${PathKeys<T[K], K>}`
          : `${Prefix}.${K}` | `${Prefix}.${K}.${PathKeys<T[K], K>}`
        : never;
    }[keyof T]
  : never;

interface User {
  name: string;
  address: {
    city: string;
    country: {
      code: string;
    };
  };
}

type UserPaths = PathKeys<User>;
// 'name' | 'address' | 'address.city' | 'address.country' | 'address.country.code'

Conditional Generics

// Different return types based on input
type ArrayOrSingle<T, Multi extends boolean> = Multi extends true
  ? T[]
  : T;

function fetch<T, Multi extends boolean = false>(
  url: string,
  options?: { multi?: Multi }
): Promise<ArrayOrSingle<T, Multi>> {
  // Implementation
  return {} as any;
}

const single = await fetch<User>('/user/1');         // User
const multiple = await fetch<User>('/users', { multi: true }); // User[]

10. Real-World Examples

Type-Safe Event Emitter

type EventMap = Record<string, any>;

type EventKey<T extends EventMap> = string & keyof T;
type EventCallback<T> = (data: T) => void;

class EventEmitter<T extends EventMap> {
  private listeners = new Map<keyof T, Set<EventCallback<any>>>();

  on<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);

    // Return unsubscribe function
    return () => this.off(event, callback);
  }

  off<K extends EventKey<T>>(event: K, callback: EventCallback<T[K]>): void {
    this.listeners.get(event)?.delete(callback);
  }

  emit<K extends EventKey<T>>(event: K, data: T[K]): void {
    this.listeners.get(event)?.forEach((callback) => callback(data));
  }
}

// Usage
interface AppEvents {
  login: { userId: string };
  logout: { timestamp: Date };
  error: { message: string; code: number };
}

const events = new EventEmitter<AppEvents>();

const unsubscribe = events.on('login', (data) => {
  console.log(`User ${data.userId} logged in`);
});

events.emit('login', { userId: '123' }); // Type-safe!
unsubscribe(); // Clean up

Type-Safe HTTP Client

interface ApiEndpoints {
  '/users': {
    GET: { response: User[] };
    POST: { body: Omit<User, 'id'>; response: User };
  };
  '/users/:id': {
    GET: { response: User };
    PUT: { body: Partial<User>; response: User };
    DELETE: { response: void };
  };
}

type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

type PathParams<P extends string> = P extends `${string}:${infer Param}/${infer Rest}`
  ? { [K in Param | keyof PathParams<Rest>]: string }
  : P extends `${string}:${infer Param}`
  ? { [K in Param]: string }
  : {};

class HttpClient<T extends Record<string, Record<Method, any>>> {
  async request<
    P extends keyof T & string,
    M extends keyof T[P] & Method
  >(
    method: M,
    path: P,
    options?: {
      params?: PathParams<P>;
      body?: T[P][M] extends { body: infer B } ? B : never;
    }
  ): Promise<T[P][M] extends { response: infer R } ? R : never> {
    // Implementation
    return {} as any;
  }
}

const client = new HttpClient<ApiEndpoints>();

// Fully type-safe API calls
const users = await client.request('GET', '/users');
const newUser = await client.request('POST', '/users', {
  body: { name: 'Alice', email: 'alice@example.com' }
});

Summary

ConceptExample
Basic Genericfunction identity<T>(val: T): T
Multiple Parametersfunction pair<T, U>(a: T, b: U): [T, U]
Constraints<T extends { length: number }>
keyof Constraint<K extends keyof T>
Generic Classclass Stack<T> { items: T[] }
Generic Interfaceinterface Repo<T> { find(id): T }
Default Parameterinterface Response<T = any>
ConditionalT extends U ? X : Y
inferT extends Array<infer U> ? U : T
Variadic Tuples[...T, ...U]
Next, we’ll explore modules and configuration!

Interview Deep-Dive

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.
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.
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.
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.