TS

Generics

TypeScript · 7 entries

Generic Functions

syntax
function fn<T>(param: T): T { ... }
const fn = <T>(param: T): T => { ... };
example
function firstElement<T>(items: T[]): T | undefined {
  return items[0];
}

const topScore = firstElement([95, 87, 72]); // number | undefined
const firstName = firstElement(["Alice", "Bob"]); // string | undefined

// Multiple type parameters
function mapEntry<K, V>(key: K, value: V): [K, V] {
  return [key, value];
}
const entry = mapEntry("age", 30); // [string, number]
output
// TypeScript infers T from the argument — no need to specify it manually

Note Type parameters are inferred from arguments in most cases. Only specify them explicitly when inference gives the wrong type. In .tsx files, arrow generics need a trailing comma: <T,>(param: T) to avoid JSX ambiguity.

Generic Interfaces

syntax
interface Name<T> {
  property: T;
  method(param: T): void;
}
example
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<boolean>;
}

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

class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    // implementation
    return null;
  }
  async findAll(): Promise<User[]> { return []; }
  async save(entity: User): Promise<User> { return entity; }
  async delete(id: string): Promise<boolean> { return true; }
}
output
// Repository<User> locks T to User for the entire implementation

Note Generic interfaces are ideal for defining contracts that work across multiple entity types — repositories, services, collections. Each implementation locks in the concrete type.

Generic Classes

syntax
class Name<T> {
  constructor(private value: T) {}
}
example
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];
  }

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

const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
const top = numberStack.pop(); // number | undefined
output
// top → 20

Note Static members of a generic class cannot reference the class type parameter — the parameter only exists on instances. If you need generic static methods, make the method itself generic rather than the class.

Generic Constraints

syntax
function fn<T extends ConstraintType>(param: T): T { ... }
example
interface HasLength {
  length: number;
}

function logLength<T extends HasLength>(item: T): T {
  console.log(`Length: ${item.length}`);
  return item;
}

logLength("hello");        // string has .length
logLength([1, 2, 3]);      // arrays have .length
// logLength(42);           // Error: number has no .length

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

const color = getProperty({ r: 255, g: 128, b: 0 }, "g"); // number
output
// Constraints restrict what types a generic can accept

Note Use extends to set a minimum requirement for a type parameter. The constraint keyof T is extremely useful — it restricts a parameter to valid property names of another type, enabling fully type-safe property access.

Default Type Parameters

syntax
function fn<T = DefaultType>(param: T): T { ... }
interface Name<T = DefaultType> { ... }
example
interface ApiResponse<TData = unknown, TError = string> {
  success: boolean;
  data?: TData;
  error?: TError;
}

// Uses defaults
const basic: ApiResponse = { success: true };

// Overrides first, keeps second default
const typed: ApiResponse<{ users: string[] }> = {
  success: true,
  data: { users: ["alice"] },
};

// Overrides both
const custom: ApiResponse<null, { code: number; msg: string }> = {
  success: false,
  error: { code: 404, msg: "Not found" },
};
output
// Default types make generics more ergonomic for common cases

Note Default type parameters work like default function arguments — they must come after required type parameters. A default does not constrain the type; add extends if you also need a constraint: <T extends object = Record<string, unknown>>.

Building Utilities with Generics

syntax
type UtilityName<T> = { [K in keyof T]: TransformedType };
example
// Make all properties optional and nullable
type Draft<T> = {
  [K in keyof T]?: T[K] | null;
};

interface Article {
  title: string;
  body: string;
  publishedAt: Date;
}

type ArticleDraft = Draft<Article>;
// { title?: string | null; body?: string | null; publishedAt?: Date | null }

const draft: ArticleDraft = {
  title: "Work in progress",
};
output
// Custom utility types combine generics with mapped types

Note This pattern (mapped types + generics) is how TypeScript's built-in utility types (Partial, Required, Readonly) are implemented. Understanding this lets you build project-specific utilities tailored to your domain.

Generics with Conditional Types

syntax
type Name<T> = T extends Condition ? TrueType : FalseType;
example
type IsArray<T> = T extends any[] ? true : false;

type Test1 = IsArray<string[]>;  // true
type Test2 = IsArray<number>;    // false

// Extracting element types
type ElementOf<T> = T extends (infer E)[] ? E : never;

type StrEl = ElementOf<string[]>;      // string
type NumEl = ElementOf<number[]>;      // number
type NoEl = ElementOf<boolean>;        // never
output
// Conditional types act like type-level if/else

Note When a conditional type receives a union as T, it distributes — the condition is applied to each union member independently. To prevent distribution, wrap both sides in brackets: [T] extends [Condition].