Skip to content

Generics#

Generics allow you to write flexible, reusable code that works with multiple types while maintaining type safety. Think of them as "type variables" or "type parameters".

Why Generics?#

Without generics:

// ❌ Not reusable - only works with numbers
function getFirstNumber(arr: number[]): number {
  return arr[0];
}

// ❌ Loses type safety - could be anything
function getFirst(arr: any[]): any {
  return arr[0];
}

With generics:

// ✅ Reusable AND type-safe
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const firstNum = getFirst([1, 2, 3]);       // Type: number
const firstStr = getFirst(["a", "b", "c"]); // Type: string

Basic Generic Functions#

Single Type Parameter#

function identity<T>(arg: T): T {
  return arg;
}

const num = identity(42);          // Type: number
const str = identity("hello");     // Type: string
const bool = identity(true);       // Type: boolean

// Explicit type annotation (usually unnecessary)
const explicit = identity<string>("hello");

Working with Arrays#

function getLastItem<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

const lastNumber = getLastItem([1, 2, 3]);      // number | undefined
const lastName = getLastItem(["a", "b", "c"]);  // string | undefined

// Reversing an array
function reverseArray<T>(arr: T[]): T[] {
  return arr.reverse();
}

const reversed = reverseArray([1, 2, 3, 4]); // number[]

Multiple Type Parameters#

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

const numberAndString = pair(42, "hello");     // [number, string]
const boolAndNumber = pair(true, 100);         // [boolean, number]

// Swapping values
function swap<T, U>(tuple: [T, U]): [U, T] {
  return [tuple[1], tuple[0]];
}

const swapped = swap([42, "hello"]); // [string, number]

Generic Constraints#

Restrict what types can be used with a generic:

extends Constraint#

// Only allow types with a length property
function logLength<T extends { length: number }>(item: T): void {
  console.log(`Length: ${item.length}`);
}

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

// Constraint with interface
interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
}

const users = [
  { id: 1, name: "Alice" },
  { id: 2, name: "Bob" }
];

const user = findById(users, 1); // { id: number; name: string } | undefined

keyof Constraint#

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

const person = { name: "Alice", age: 25, city: "NYC" };

const name = getProperty(person, "name");  // Type: string
const age = getProperty(person, "age");    // Type: number
// getProperty(person, "invalid");         // ❌ Error

// Setting a property
function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

setProperty(person, "age", 26);        // ✅ OK
// setProperty(person, "age", "26");   // ❌ Error: string is not assignable to number

Generic Interfaces#

interface Container<T> {
  value: T;
  getValue(): T;
  setValue(value: T): void;
}

const stringContainer: Container<string> = {
  value: "hello",
  getValue() {
    return this.value;
  },
  setValue(value: string) {
    this.value = value;
  }
};

// Generic interface with multiple parameters
interface KeyValuePair<K, V> {
  key: K;
  value: V;
}

const pair1: KeyValuePair<string, number> = {
  key: "age",
  value: 25
};

const pair2: KeyValuePair<number, string> = {
  key: 1,
  value: "first"
};

Generic Classes#

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

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

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

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

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

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

const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);
console.log(numberQueue.dequeue()); // 1

const stringQueue = new Queue<string>();
stringQueue.enqueue("first");
stringQueue.enqueue("second");
console.log(stringQueue.peek()); // "first"

Generic Class with Constraints#

interface Comparable {
  compareTo(other: this): number;
}

class SortedList<T extends Comparable> {
  private items: T[] = [];

  add(item: T): void {
    this.items.push(item);
    this.items.sort((a, b) => a.compareTo(b));
  }

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

class Version implements Comparable {
  constructor(
    public major: number,
    public minor: number,
    public patch: number
  ) {}

  compareTo(other: Version): number {
    if (this.major !== other.major) return this.major - other.major;
    if (this.minor !== other.minor) return this.minor - other.minor;
    return this.patch - other.patch;
  }
}

const versions = new SortedList<Version>();
versions.add(new Version(2, 0, 0));
versions.add(new Version(1, 5, 3));
versions.add(new Version(1, 6, 0));
console.log(versions.getAll()); // Sorted by version

Generic Type Aliases#

type Result<T, E = Error> = 
  | { success: true; data: T }
  | { success: false; error: E };

function fetchUser(id: string): Result<User> {
  if (id) {
    return { success: true, data: { id, name: "Alice" } };
  }
  return { success: false, error: new Error("User not found") };
}

const result = fetchUser("123");
if (result.success) {
  console.log(result.data); // User
} else {
  console.error(result.error); // Error
}

// Generic wrapper type
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;

let name: Nullable<string> = null;
let age: Optional<number> = undefined;
let email: Maybe<string> = "test@example.com";

Conditional Types#

// Basic conditional type
type IsString<T> = T extends string ? true : false;

type A = IsString<string>;  // true
type B = IsString<number>;  // false

// Practical example: Extract array element type
type ArrayElement<T> = T extends (infer U)[] ? U : T;

type NumArray = ArrayElement<number[]>;  // number
type StrArray = ArrayElement<string[]>;  // string
type NotArray = ArrayElement<boolean>;   // boolean

// NonNullable implementation
type MyNonNullable<T> = T extends null | undefined ? never : T;

type C = MyNonNullable<string | null>;       // string
type D = MyNonNullable<number | undefined>;  // number

Mapped Types#

// Make all properties optional
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// Make all properties required
type Required<T> = {
  [P in keyof T]-?: T[P];
};

// Make all properties readonly
type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

// Pick specific properties
type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

// Usage
interface User {
  id: number;
  name: string;
  email: string;
  age: number;
}

type PartialUser = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number; }

type UserPreview = Pick<User, "id" | "name">;
// { id: number; name: string; }

Practical Examples#

Generic API Response#

interface ApiResponse<T> {
  data: T;
  status: number;
  error: string | null;
  timestamp: number;
}

interface PaginatedResponse<T> {
  items: T[];
  page: number;
  pageSize: number;
  total: number;
  hasMore: boolean;
}

async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
  try {
    const response = await fetch(url);
    const data = await response.json();

    return {
      data,
      status: response.status,
      error: null,
      timestamp: Date.now()
    };
  } catch (error) {
    return {
      data: {} as T,
      status: 500,
      error: error instanceof Error ? error.message : "Unknown error",
      timestamp: Date.now()
    };
  }
}

// Usage
interface User {
  id: number;
  name: string;
}

const userResponse = await fetchData<User>("/api/user/1");
const usersResponse = await fetchData<PaginatedResponse<User>>("/api/users");

Generic Repository Pattern#

interface Entity {
  id: number | string;
}

class Repository<T extends Entity> {
  private items: Map<T["id"], T> = new Map();

  create(item: T): T {
    this.items.set(item.id, item);
    return item;
  }

  findById(id: T["id"]): T | undefined {
    return this.items.get(id);
  }

  update(id: T["id"], updates: Partial<T>): T | undefined {
    const item = this.items.get(id);
    if (item) {
      const updated = { ...item, ...updates };
      this.items.set(id, updated);
      return updated;
    }
    return undefined;
  }

  delete(id: T["id"]): boolean {
    return this.items.delete(id);
  }

  findAll(): T[] {
    return Array.from(this.items.values());
  }

  findWhere(predicate: (item: T) => boolean): T[] {
    return this.findAll().filter(predicate);
  }
}

// Usage
interface User extends Entity {
  id: number;
  name: string;
  email: string;
}

const userRepo = new Repository<User>();

userRepo.create({ id: 1, name: "Alice", email: "alice@example.com" });
userRepo.create({ id: 2, name: "Bob", email: "bob@example.com" });

const user = userRepo.findById(1);
const allUsers = userRepo.findAll();
const activeUsers = userRepo.findWhere(u => u.email.includes("example"));

Generic State Machine#

type State<T> = {
  data: T;
  status: "idle" | "loading" | "success" | "error";
  error: string | null;
};

type Action<T> =
  | { type: "FETCH_START" }
  | { type: "FETCH_SUCCESS"; payload: T }
  | { type: "FETCH_ERROR"; error: string }
  | { type: "RESET" };

function createReducer<T>(initialData: T) {
  return function reducer(state: State<T>, action: Action<T>): State<T> {
    switch (action.type) {
      case "FETCH_START":
        return { ...state, status: "loading", error: null };

      case "FETCH_SUCCESS":
        return {
          data: action.payload,
          status: "success",
          error: null
        };

      case "FETCH_ERROR":
        return { ...state, status: "error", error: action.error };

      case "RESET":
        return { data: initialData, status: "idle", error: null };

      default:
        return state;
    }
  };
}

// Usage
interface User {
  id: number;
  name: string;
}

const userReducer = createReducer<User>({ id: 0, name: "" });

let state: State<User> = {
  data: { id: 0, name: "" },
  status: "idle",
  error: null
};

state = userReducer(state, { type: "FETCH_START" });
state = userReducer(state, {
  type: "FETCH_SUCCESS",
  payload: { id: 1, name: "Alice" }
});

Generic Event Emitter#

type EventMap = Record<string, any>;

type EventHandler<T = any> = (event: T) => void;

class TypedEventEmitter<Events extends EventMap> {
  private listeners = new Map<keyof Events, EventHandler[]>();

  on<K extends keyof Events>(
    event: K,
    handler: EventHandler<Events[K]>
  ): () => void {
    const handlers = this.listeners.get(event) || [];
    handlers.push(handler);
    this.listeners.set(event, handlers);

    return () => this.off(event, handler);
  }

  off<K extends keyof Events>(
    event: K,
    handler: EventHandler<Events[K]>
  ): void {
    const handlers = this.listeners.get(event);
    if (handlers) {
      const index = handlers.indexOf(handler);
      if (index !== -1) {
        handlers.splice(index, 1);
      }
    }
  }

  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    const handlers = this.listeners.get(event);
    if (handlers) {
      handlers.forEach(handler => handler(data));
    }
  }
}

// Usage
interface AppEvents {
  userLogin: { userId: string; timestamp: number };
  userLogout: { userId: string };
  dataUpdate: { collection: string; id: string };
  error: { message: string; code: number };
}

const emitter = new TypedEventEmitter<AppEvents>();

// Type-safe event listeners
emitter.on("userLogin", (data) => {
  console.log(`User ${data.userId} logged in`); // data is typed correctly!
});

emitter.on("error", (data) => {
  console.error(`Error ${data.code}: ${data.message}`);
});

// Type-safe event emission
emitter.emit("userLogin", { userId: "123", timestamp: Date.now() });
// emitter.emit("userLogin", { invalid: true }); // ❌ Error

Best Practices#

  1. Use descriptive type parameter names

    // ✅ Clear intent
    function map<Input, Output>(arr: Input[], fn: (item: Input) => Output): Output[]
    
    // ❌ Less clear
    function map<T, U>(arr: T[], fn: (item: T) => U): U[]
    

  2. Add constraints when needed

    function sort<T extends { id: number }>(items: T[]): T[]
    

  3. Use type inference when possible

    const result = identity(42); // No need for identity<number>(42)
    

  4. Provide default type parameters for common cases

    interface Response<T = any> { data: T; }
    

  5. Don't over-genericize

    // ❌ Too generic
    function process<T, U, V, W>(a: T, b: U, c: V): W { /* ... */ }
    
    // ✅ Simpler when possible
    function process(a: string, b: number): boolean { /* ... */ }
    

Next Steps#