TypeScript Generics Guide — From Constraints to Utility Types

Why Generics Are Needed

When you want to apply the same logic to different types, using any throws away type safety. Generics parameterize types, achieving both reusability and type safety simultaneously.

This article covers generic functions, interfaces, classes, constraints, utility types, and conditional types step by step.

Basic Generic Functions

Generic functions determine their types at call time. Declare type parameters inside angle brackets.

// Using any: type information is lost
function identityAny(value: any): any {
  return value;
}
const resultAny = identityAny("hello"); // Type: any — no autocomplete

// Using generics: type information is preserved
function identity<T>(value: T): T {
  return value;
}

// Types are automatically inferred at call time
const str = identity("hello");      // Type: string
const num = identity(42);           // Type: number
const arr = identity([1, 2, 3]);    // Type: number[]

// Explicit type specification is also possible
const explicit = identity<string>("world"); // Type: string

// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}
const p = pair("name", 30); // Type: [string, number]
console.log(p); // ["name", 30]

Generic Interfaces and Types

Generics can also be applied to interfaces and type aliases. They are commonly used for API responses, collection structures, and more.

// Generic interface: API response wrapper
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: Date;
}

// Reused across different response types
interface User {
  id: number;
  name: string;
  email: string;
}

interface Product {
  id: number;
  title: string;
  price: number;
}

// Same wrapper, different data types
const userResponse: ApiResponse<User> = {
  data: { id: 1, name: "Alice", email: "alice@example.com" },
  status: 200,
  message: "Success",
  timestamp: new Date(),
};

const productResponse: ApiResponse<Product[]> = {
  data: [
    { id: 1, title: "Laptop", price: 1500 },
    { id: 2, title: "Mouse", price: 35 },
  ],
  status: 200,
  message: "Success",
  timestamp: new Date(),
};

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

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) {
    return { success: false, error: "Cannot divide by zero" };
  }
  return { success: true, data: a / b };
}

const result = divide(10, 3);
if (result.success) {
  console.log(result.data.toFixed(2)); // "3.33" — data is narrowed to number
} else {
  console.log(result.error); // error is narrowed to string
}

Generic Constraints (extends)

You can constrain generic types to only accept types that have specific properties or methods.

// Constraint: only accept types with a length property
interface HasLength {
  length: number;
}

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

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

// keyof constraint: only accept keys of an object
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: "Alice", age: 30, email: "alice@example.com" };
const name = getProperty(user, "name");   // Type: string
const age = getProperty(user, "age");     // Type: number
// getProperty(user, "phone");            // Error! "phone" is not a key

// Combining multiple constraints
interface Identifiable {
  id: number;
}
interface Nameable {
  name: string;
}

function displayEntity<T extends Identifiable & Nameable>(entity: T): string {
  return `[${entity.id}] ${entity.name}`;
}

console.log(displayEntity({ id: 1, name: "Alice", email: "alice@test.com" }));
// "[1] Alice"

Generic Classes

Applying generics to classes enables type-safe collections and service layers.

// Generic stack implementation
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;
  }

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

// Number stack
const numberStack = new Stack<number>();
numberStack.push(10);
numberStack.push(20);
numberStack.push(30);
console.log(numberStack.pop());  // 30
console.log(numberStack.peek()); // 20
console.log(numberStack.size);   // 2

// String stack
const stringStack = new Stack<string>();
stringStack.push("first");
stringStack.push("second");
console.log(stringStack.pop()); // "second"

// Generic repository pattern
interface Entity {
  id: number;
}

class Repository<T extends Entity> {
  private store: Map<number, T> = new Map();

  save(entity: T): void {
    this.store.set(entity.id, entity);
  }

  findById(id: number): T | undefined {
    return this.store.get(id);
  }

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

  delete(id: number): boolean {
    return this.store.delete(id);
  }
}

interface Product extends Entity {
  title: string;
  price: number;
}

const productRepo = new Repository<Product>();
productRepo.save({ id: 1, title: "Keyboard", price: 89 });
productRepo.save({ id: 2, title: "Monitor", price: 450 });

console.log(productRepo.findById(1)); // { id: 1, title: "Keyboard", price: 89 }
console.log(productRepo.findAll().length); // 2

Utility Types

TypeScript provides built-in utility types based on generics. They are used to create new types by transforming existing ones.

interface User {
  id: number;
  name: string;
  email: string;
  age: number;
  createdAt: Date;
}

// Partial: make all properties optional
type UpdateUserDto = Partial<User>;
// { id?: number; name?: string; email?: string; age?: number; createdAt?: Date; }

function updateUser(id: number, updates: Partial<User>): void {
  console.log(`Updating user ${id}:`, updates);
}
updateUser(1, { name: "New Name" }); // Only pass some properties

// Required: make all properties required
type RequiredUser = Required<User>;

// Pick: select specific properties only
type UserSummary = Pick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string; }

// Omit: exclude specific properties
type UserWithoutDates = Omit<User, "createdAt">;
// { id: number; name: string; email: string; age: number; }

// Record: key-value pair type
type UserRoles = Record<string, "admin" | "user" | "guest">;
const roles: UserRoles = {
  alice: "admin",
  bob: "user",
  charlie: "guest",
};

// Readonly: make all properties read-only
type ImmutableUser = Readonly<User>;
const frozenUser: ImmutableUser = {
  id: 1,
  name: "Alice",
  email: "alice@test.com",
  age: 30,
  createdAt: new Date(),
};
// frozenUser.name = "Changed"; // Error! readonly property

// Extract / Exclude: filter union types
type AllStatus = "active" | "inactive" | "pending" | "deleted";
type ActiveStatus = Extract<AllStatus, "active" | "pending">;
// "active" | "pending"
type ArchivedStatus = Exclude<AllStatus, "active" | "pending">;
// "inactive" | "deleted"

Practical Tips

  • Generic naming conventions: Use meaningful abbreviations like T (Type), K (Key), V (Value), E (Element). For complex cases, use prefixes like TData, TError.
  • Avoid excessive generics: If a type parameter is only used once, generics may not be necessary. Keep it simple.
  • Default type parameters: Providing defaults like type Result<T, E = Error> lets callers omit them.
  • keyof + index access types: A core pattern for safely working with object keys and value types.
  • Combine utility types: Combining Partial, Pick, and Omit lets you quickly derive DTOs, form types, and more from existing interfaces.

Was this article helpful?