Generic Patterns

Difficulty: Intermediate

As you become more comfortable with generics, several advanced patterns emerge that are widely used in production TypeScript codebases. Multiple type parameters allow functions to track relationships between different types. Default type parameters provide fallback types when the caller doesn't specify one. And generic utility patterns let you build powerful, reusable abstractions that form the foundation of TypeScript's type system.

Multiple type parameters are used when a function works with more than one type that needs to be tracked independently. A `Map<K, V>` has separate type parameters for keys and values. A `merge<A, B>(obj1: A, obj2: B): A & B` function tracks both input types and returns their intersection. The convention is to use single uppercase letters (`T`, `U`, `V`) or descriptive names (`TKey`, `TValue`) for type parameters.

Default type parameters work like default function parameters but for types. In `function createState<T = string>(initial: T): T`, the type parameter `T` defaults to `string` if not provided. This is common in library APIs where a sensible default exists but customization is needed. React's `useState<T>` uses this pattern extensively.

Generic utility patterns include type-safe event emitters, factory functions, and data transformation pipelines. A particularly powerful pattern is the generic constraint combined with `keyof` for type-safe property access: `function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K>`. Understanding these patterns is essential for working with modern TypeScript frameworks and libraries, and they frequently appear in technical interviews.

Code examples

Multiple type parameters

// Transform a record's values from one type to another
function mapValues<K extends string, V, R>(
  obj: Record<K, V>,
  fn: (value: V, key: K) => R
): Record<K, R> {
  const result = {} as Record<K, R>;
  for (const key in obj) {
    result[key] = fn(obj[key], key);
  }
  return result;
}

const prices = { apple: 1.5, banana: 0.75, cherry: 3.0 };
const formatted = mapValues(prices, (v) => `${v.toFixed(2)}`);

console.log(formatted.apple);
console.log(formatted.banana);
console.log(formatted.cherry);

Three type parameters track the key type (K), input value type (V), and output value type (R). TypeScript infers all three from the arguments, so the return type has the same keys but transformed values.

Default type parameters

// Generic container with default type
function createCollection<T = string>(): { items: T[]; add: (item: T) => void; getAll: () => T[] } {
  const items: T[] = [];
  return {
    items,
    add(item: T) { items.push(item); },
    getAll() { return [...items]; }
  };
}

// Uses default type (string)
const names = createCollection();
names.add("Alice");
names.add("Bob");

// Explicit type parameter
const numbers = createCollection<number>();
numbers.add(42);
numbers.add(100);

console.log(names.getAll().join(", "));
console.log(numbers.getAll().join(", "));

Default type parameters provide a fallback when the type isn't specified. `createCollection()` defaults to string, while `createCollection<number>()` overrides it.

Type-safe pick utility

// Pick specific keys from an object, fully type-safe
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
  const result = {} as Pick<T, K>;
  for (const key of keys) {
    result[key] = obj[key];
  }
  return result;
}

const user = {
  id: 1,
  name: "Alice",
  email: "alice@example.com",
  age: 30,
  role: "admin"
};

const public_info = pick(user, ["name", "role"]);
console.log(public_info);

const contact = pick(user, ["name", "email"]);
console.log(contact);

The `K extends keyof T` constraint ensures you can only pick keys that exist on the object. The return type `Pick<T, K>` correctly describes an object with only the selected properties.

Key points

Concepts covered

Multiple type parameters, Default type parameters, Generic utility patterns, Conditional generics