mdashikjs/blog
All posts
TypeScript Generics: A Practical Guide Beyond the Basics
Backend

TypeScript Generics: A Practical Guide Beyond the Basics

Backend5 min

TypeScript Generics: A Practical Guide Beyond the Basics

Generics are TypeScript's most powerful feature and its most confusing one. Here are the patterns I use daily — not academic type theory, but practical code that makes refactoring safe and APIs self-documenting.

TypeScriptGenericsPatternsNode.js
Share:

Generic functions that infer

The best generic is one the caller never notices:

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = first([1, 2, 3]);      // type: number | undefined
const str = first(['a', 'b']);      // type: string | undefined

TypeScript infers T from the argument. The caller does not write first<number>([1, 2, 3]) — they just call the function and get correct types.

Constrained generics

When a generic needs to have certain properties, constrain it with extends:

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

const user = { name: 'Alice', age: 30 };
const name = getProperty(user, 'name');  // type: string
const age = getProperty(user, 'age');    // type: number
getProperty(user, 'email');              // Error: 'email' is not in keyof user

The constraint K extends keyof T ensures you can only access properties that exist. The return type T[K] is specific — not any, not unknown, but the exact type of that property.

Generic API response wrapper

Every API has a standard response shape. Make it generic:

type ApiResponse<T> = {
  data: T;
  meta: {
    requestId: string;
    timestamp: string;
  };
};

type PaginatedResponse<T> = ApiResponse<T[]> & {
  pagination: {
    page: number;
    pageSize: number;
    total: number;
  };
};

// Usage
async function getUsers(): Promise<PaginatedResponse<User>> {
  // TypeScript knows the return shape exactly
}

const response = await getUsers();
response.data[0].email;  // Autocomplete works
response.pagination.total; // Type-safe

Generic repository pattern

interface Repository<T extends { id: string }> {
  findById(id: string): Promise<T | null>;
  findMany(filter: Partial<T>): Promise<T[]>;
  create(data: Omit<T, 'id'>): Promise<T>;
  update(id: string, data: Partial<Omit<T, 'id'>>): Promise<T>;
  delete(id: string): Promise<void>;
}

class PrismaRepository<T extends { id: string }> implements Repository<T> {
  constructor(private model: any) {}

  async findById(id: string): Promise<T | null> {
    return this.model.findUnique({ where: { id } });
  }

  async create(data: Omit<T, 'id'>): Promise<T> {
    return this.model.create({ data });
  }

  // ... other methods
}

// Type-safe for each entity
const userRepo = new PrismaRepository<User>(prisma.user);
const postRepo = new PrismaRepository<Post>(prisma.post);

Conditional types for API contracts

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';

type RequestBody<M extends HttpMethod> =
  M extends 'GET' | 'DELETE' ? never :
  M extends 'POST' | 'PUT' ? Record<string, unknown> :
  never;

function request<M extends HttpMethod>(
  method: M,
  url: string,
  ...args: RequestBody<M> extends never ? [] : [body: RequestBody<M>]
) {
  // ...
}

request('GET', '/api/users');            // No body allowed
request('POST', '/api/users', { name: 'Alice' }); // Body required
request('GET', '/api/users', {});        // Error: too many arguments

The conditional type makes body required for POST/PUT and forbidden for GET/DELETE.

The golden rule

If a generic type parameter appears only once in the function signature, you do not need it:

// Bad: T is used once, just use the concrete type
function logValue<T>(value: T): void {
  console.log(value);
}

// Good: no generic needed
function logValue(value: unknown): void {
  console.log(value);
}

Generics connect types across multiple positions — input to output, argument to argument. If there is nothing to connect, use a concrete type.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.