DEV Community

Alex Chen
Alex Chen

Posted on

TypeScript Generics Explained: The Practical Guide

TypeScript Generics Explained: The Practical Guide

Generics aren't as scary as they look. They're just "types for types."

What Are Generics?

// Without generics — any type, no type safety
function identity(value: any): any {
  return value;
}

// With generics — type-safe!
function identity<T>(value: T): T {
  return value;
}

const num = identity(42);         // type: number
const str = identity('hello');    // type: string
const arr = identity([1, 2, 3]);  // type: number[]

// TypeScript INFERS the type from usage!
// You don't have to write identity<number>(42)
Enter fullscreen mode Exit fullscreen mode

Generic Functions

// First element of array
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

first([1, 2, 3]);     // number | undefined
first(['a', 'b']);    // string | undefined

// Merge two objects
function merge<T, U>(a: T, b: U): T & U {
  return { ...a, ...b };
}

const result = merge({ name: 'Alex' }, { age: 30 });
// type: { name: string } & { age: number }
// result.name → string ✅
// result.age → number ✅
// result.xyz → TypeScript error ✅

// Map over array with transformation
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
  return arr.map(fn);
}

const names = map(
  [{ id: 1, name: 'Alex' }, { id: 2, name: 'Sam' }],
  user => user.name
);
// type: string[] → ['Alex', 'Sam']
Enter fullscreen mode Exit fullscreen mode

Generic Interfaces

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

// Reuse the same interface for different data types
type UserResponse = ApiResponse<{ id: number; name: string }>;
type PostResponse = ApiResponse<{ id: number; title: string; body: string }>;

const userRes: UserResponse = {
  data: { id: 1, name: 'Alex' },
  status: 200,
  message: 'OK',
  timestamp: '2026-05-16T00:00:00Z',
};

// Pagination
interface PaginatedResponse<T> {
  data: T[];
  page: number;
  limit: number;
  total: number;
  hasMore: boolean;
}

const usersPage: PaginatedResponse<{ id: number; name: string }> = {
  data: [{ id: 1, name: 'Alex' }],
  page: 1,
  limit: 20,
  total: 100,
  hasMore: true,
};
Enter fullscreen mode Exit fullscreen mode

Generic Classes

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

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

  find(predicate: (item: T) => boolean): T | undefined {
    return this.items.find(predicate);
  }

  getAll(): T[] {
    return [...this.items];
  }

  remove(predicate: (item: T) => boolean): T | undefined {
    const index = this.items.findIndex(predicate);
    if (index === -1) return undefined;
    return this.items.splice(index, 1)[0];
  }
}

// Type-safe stores!
const userStore = new DataStore<{ id: number; name: string }>();
userStore.add({ id: 1, name: 'Alex' });
userStore.add({ id: 'wrong', name: 'Sam' }); // Error! id must be number

const postStore = new DataStore<{ title: string; published: boolean }>();
postStore.add({ title: 'Hello', published: true });
Enter fullscreen mode Exit fullscreen mode

Constraining Generics

// T must have an 'id' property
function findById<T extends { id: number }>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

findById([{ id: 1, name: 'Alex' }], 1);  // Works!
findById([{ name: 'Alex' }], 1);         // Error! No 'id' property

// T must have a 'length' property
function logLength<T extends { length: number }>(item: T): void {
  console.log(item.length);
}

logLength('hello');     // 5 ✅
logLength([1, 2, 3]);   // 3 ✅
logLength(42);          // Error! number has no length

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

const user = { name: 'Alex', age: 30, email: 'alex@example.com' };
getProperty(user, 'name');  // string
getProperty(user, 'age');   // number
getProperty(user, 'xyz');   // Error! 'xyz' is not a key of user
Enter fullscreen mode Exit fullscreen mode

Generic Utility Types

// Partial — all properties optional
type PartialUser = Partial<{ name: string; age: number; email: string }>;
const update: PartialUser = { name: 'New Name' }; // Only update name

// Required — all properties required
type RequiredUser = Required<{ name?: string; age?: number }>;
// Both name and age are now required

// Pick — select specific properties
type UserPreview = Pick<{ id: number; name: string; email: string; role: string }, 'id' | 'name'>;
// { id: number; name: string }

// Omit — exclude specific properties
type CreateUser = Omit<{ id: number; name: string; email: string }, 'id'>;
// { name: string; email: string }

// Record — key-value type
type Roles = Record<string, string[]>;
const roles: Roles = {
  admin: ['read', 'write', 'delete'],
  user: ['read', 'write'],
};

// Readonly
type Config = Readonly<{ host: string; port: number }>;
const config: Config = { host: 'localhost', port: 3000 };
config.port = 8080; // Error! Cannot assign to 'port' because it is read-only
Enter fullscreen mode Exit fullscreen mode

Conditional Types

// If-else for types
type IsString<T> = T extends string ? true : false;
type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Extract return type of a function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Fn = (x: number) => string;
type Result = ReturnType<Fn>; // string

// NonNullable
type Safe<T> = T extends null | undefined ? never : T;
type A = Safe<string | null>; // string
type B = Safe<null>;          // never
Enter fullscreen mode Exit fullscreen mode

Real-World Patterns

Type-safe API client

async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
  const response = await fetch(url, options);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}

// Usage — fully typed!
const users = await fetchApi<{ id: number; name: string }[]>('/api/users');
const user = await fetchApi<{ id: number; name: string }>('/api/users/1');
Enter fullscreen mode Exit fullscreen mode

Type-safe event emitter

type Events = {
  login: { userId: number; timestamp: Date };
  logout: { userId: number };
  error: { message: string; code: number };
};

class EventEmitter<T extends Record<string, any>> {
  private handlers = new Map<keyof T, Set<Function>>();

  on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set());
    this.handlers.get(event)!.add(handler);
  }

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

const emitter = new EventEmitter<Events>();
emitter.on('login', (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});
emitter.on('login', (data) => {
  console.log(data.wrong); // Error! 'wrong' doesn't exist on login event
});
Enter fullscreen mode Exit fullscreen mode

Are you using generics in your TypeScript code? What patterns do you use?

Follow @armorbreak for more TypeScript content.

Top comments (0)