TypeScript SeriesPart 3 of 4

TypeScript Generics — Write Once, Work Everywhere

Jan 15, 2025·4 min read·
TypeScriptGenericsAdvanced

Generics are the mechanism that lets TypeScript be both flexible and type-safe at the same time. Instead of giving up type information to handle multiple types, generics let you describe a function or class that works with any type while preserving the specific type in context.

The Problem Generics Solve

Without generics, you face a choice: be specific (and write duplicate code) or use any (and lose safety).

// Too specific — only works for numbers
function firstNumber(arr: number[]): number {
  return arr[0];
}

// Too loose — loses all type information
function firstAny(arr: any[]): any {
  return arr[0];
}

const name = firstAny(['Alice', 'Bob']); // type is `any`, not `string`

Generics solve this by letting you say "I don't know the type yet, but whatever it is, keep track of it":

function first<T>(arr: T[]): T {
  return arr[0];
}

const name = first(['Alice', 'Bob']); // type is `string` ✓
const score = first([95, 87, 72]);    // type is `number` ✓

The T is a type parameter — a placeholder that TypeScript fills in at each call site.

Generic Functions

Type parameters are inferred from arguments, so you rarely need to specify them explicitly:

function wrap<T>(value: T): { value: T } {
  return { value };
}

const wrapped = wrap(42);        // { value: number }
const wrappedStr = wrap('hello'); // { value: string }

You can also pass the type explicitly when inference isn't possible:

const empty = wrap<string[]>([]); // { value: string[] }

Generic Constraints

Sometimes you need to restrict what types are allowed. Use extends to set a constraint:

function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength('hello');      // ✓ string has .length
getLength([1, 2, 3]);    // ✓ array has .length
getLength(42);           // ✗ number has no .length

A common constraint is keyof — ensuring a key actually exists on an object:

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

const user = { name: 'Prabhash', age: 25 };
const name = getProperty(user, 'name'); // type is string ✓
const age  = getProperty(user, 'age');  // type is number ✓
getProperty(user, 'email');             // ✗ 'email' doesn't exist

Generic Interfaces

Generics work in interfaces too, which is how most data structures are typed:

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

interface User {
  id: string;
  name: string;
}

async function fetchUser(id: string): Promise<ApiResponse<User>> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

const response = await fetchUser('123');
console.log(response.data.name); // TypeScript knows this is a string

Multiple Type Parameters

Functions can accept more than one type parameter:

function zip<A, B>(as: A[], bs: B[]): [A, B][] {
  return as.map((a, i) => [a, bs[i]]);
}

const pairs = zip(['a', 'b', 'c'], [1, 2, 3]);
// type: [string, number][]

Default Type Parameters

Like function defaults, you can give a type parameter a default:

interface PaginatedResult<T, Meta = Record<string, unknown>> {
  items: T[];
  total: number;
  meta: Meta;
}

// Uses default for Meta
type UserList = PaginatedResult<User>;

// Explicit Meta type
type UserListWithCursor = PaginatedResult<User, { cursor: string }>;

Built-in Generic Types

TypeScript ships with many generic types you'll use constantly:

// Promise<T> — async return types
async function loadConfig(): Promise<Config> { ... }

// Array<T> — same as T[]
const ids: Array<string> = [];

// Record<K, V> — typed object maps
const cache: Record<string, User> = {};

// Partial<T> — all fields optional
function updateUser(id: string, changes: Partial<User>) { ... }

// Required<T> — all fields required
// Readonly<T> — all fields readonly
// Pick<T, K> — subset of keys
// Omit<T, K> — all keys except K

A Practical Pattern: Repository

Generics shine when building reusable infrastructure:

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

// Reuse for any entity
const users = new InMemoryRepository<User>();
const posts = new InMemoryRepository<BlogPost>();

Write the data layer once — TypeScript ensures each repository handles the right entity type.

In Part 4, we'll go deeper into TypeScript's type system — conditional types, mapped types, and template literal types that let you compute new types from existing ones.