TypeScript Advanced Types — Generics Deep Dive

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Generics are TypeScript's most powerful feature for writing reusable code. They allow you to write functions, classes, and interfaces that work with any type while maintaining type safety. Understanding generics deeply unlocks the ability to build sophisticated, reusable libraries and frameworks.

Understanding Generics

A generic is a type parameter that becomes concrete when used. Think of it like a template:

// Without generics - loses type information
function identity(value: any): any {
  return value;
}

const result = identity("hello");
// result type is 'any' - we lose information

// With generics - preserves type information
function identity<T>(value: T): T {
  return value;
}

const result = identity("hello"); // type is 'string'

Generic Constraints

Constraints limit what types a generic can accept:

// Without constraints - T could be anything
function getProperty<T>(obj: T, key: string): any {
  return obj[key]; // Error: property access on unknown
}

// With constraints - T must have string properties
function getProperty<T extends { [key: string]: any }>(
  obj: T,
  key: string
): any {
  return obj[key];
}

// More practical constraint
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength("hello"); // 5
getLength([1, 2, 3]); // 3
getLength(42); // Error: number has no length

Multiple Type Parameters

function map<T, U>(array: T[], callback: (item: T) => U): U[] {
  return array.map(callback);
}

const numbers = [1, 2, 3];
const strings = map(numbers, n => n.toString());
// strings: string[]

// Constrained multiple parameters
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const result = merge({ a: 1 }, { b: 2 });
// result: { a: number } & { b: number }

Generic Defaults

// Generic with default type
type Container<T = string> = {
  value: T;
};

const stringContainer: Container = { value: "hello" };
const numberContainer: Container<number> = { value: 42 };

// Function with default generic
function create<T = object>(value?: T): T {
  return value || ({} as T);
}

const obj = create(); // type is 'object'
const num = create<number>(5); // type is 'number'

Generic Classes

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

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

  getById(index: number): T | undefined {
    return this.items[index];
  }

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

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

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

const userRepo = new Repository<User>();
userRepo.add({ id: 1, name: "Alice" });
// All methods now work with User type

const users = userRepo.getAll();
// users: User[]

Generic Interfaces

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

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

// Usage
async function fetchUsers(): Promise<ApiResponse<User[]>> {
  const response = await fetch("/api/users");
  return response.json();
}

async function searchPosts(
  query: string
): Promise<PaginatedResponse<Post>> {
  const response = await fetch(`/api/posts?q=${query}`);
  return response.json();
}

Conditional Generics

// Type conditional on generic parameter
type IsString<T> = T extends string ? true : false;

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

// Practical example - extract array type
type ArrayElement<T> = T extends (infer E)[] ? E : T;

type StringArray = ArrayElement<string[]>; // string
type SingleValue = ArrayElement<42>; // 42

// Real-world use case
function flatten<T>(
  value: T extends any[] ? T : T[]
): T extends any[] ? T : T[] {
  return Array.isArray(value) ? value : [value];
}

Advanced Generic Patterns

Builder Pattern

class QueryBuilder<T> {
  private conditions: string[] = [];
  private limit_value?: number;

  where(condition: string): QueryBuilder<T> {
    this.conditions.push(condition);
    return this;
  }

  limit(n: number): QueryBuilder<T> {
    this.limit_value = n;
    return this;
  }

  build(): string {
    let query = `SELECT * FROM ${this.getTableName()}`;
    if (this.conditions.length) {
      query += ` WHERE ${this.conditions.join(" AND ")}`;
    }
    if (this.limit_value) {
      query += ` LIMIT ${this.limit_value}`;
    }
    return query;
  }

  private getTableName(): string {
    return "table";
  }
}

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

const query = new QueryBuilder<Product>()
  .where("price > 100")
  .limit(10)
  .build();

Mapped Types with Generics

// Create getters for all properties
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

interface User {
  name: string;
  age: number;
}

type UserGetters = Getters<User>;
// {
//   getName: () => string;
//   getAge: () => number;
// }

Generic Utility Functions

// Type-safe cache
function createCache<T>(): {
  get(key: string): T | undefined;
  set(key: string, value: T): void;
} {
  const store = new Map<string, T>();
  return {
    get: (key) => store.get(key),
    set: (key, value) => store.set(key, value),
  };
}

const userCache = createCache<User>();
userCache.set("user-1", { id: 1, name: "Alice" });
const user = userCache.get("user-1"); // User | undefined

// Compose functions with generics
function compose<A, B, C>(
  f: (a: A) => B,
  g: (b: B) => C
): (a: A) => C {
  return (a) => g(f(a));
}

const toString = (n: number) => n.toString();
const toUpperCase = (s: string) => s.toUpperCase();
const composed = compose(toString, toUpperCase);
const result = composed(42); // "42"

Performance Considerations

  1. Avoid excessive constraints - simple generics compile faster
  2. Use default types - reduces instantiation overhead
  3. Cache instantiated types - TypeScript caches generated types

Common Pitfalls

  • Over-constraining: Make constraints only as restrictive as needed
  • Forgetting type inference: Let TypeScript infer when possible
  • Mixing any with generics: Defeats the purpose of type safety

FAQ

Q: When should I use generics vs overloads? A: Generics preserve type relationships across parameters. Use overloads when you have different return types for different inputs. Generics are usually better for reusable logic.

Q: Can generics have circular constraints? A: Yes, but carefully. T extends { next: T } is valid and useful for linked structures, but use sparingly to avoid infinite recursion.

Q: How do generics affect runtime performance? A: Generics are compile-time only. They completely disappear in JavaScript, so there's zero runtime cost.


Mastering generics transforms you from writing TypeScript code to writing TypeScript properly. Generics are how you build frameworks, libraries, and systems that scale. With constraints, defaults, and conditional types, you can express any type relationship your code needs.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro