M
MeshWorld.
TypeScript Generics Type Safety Advanced Types Programming Frontend Backend Developer Tools 11 min read

TypeScript Generics: Real-World Patterns You Actually Need

Vishnu
By Vishnu

TypeScript generics separate professional code from stringly-typed chaos. They let you write flexible, reusable components while keeping type safety. This guide covers the patterns you’ll actually use — not academic type gymnastics.

:::note[TL;DR]

  • Generics add type parameters to functions, interfaces, and classes: <T>
  • Constraints limit what types can be used: <T extends { id: string }>
  • Conditional types create type branches: T extends U ? X : Y
  • Mapped types transform object properties: { [K in keyof T]: V }
  • Utility types (Pick, Omit, Partial, Required) cover 80% of use cases
  • infer keyword extracts types from complex structures :::

Basic Generics

Generic Functions

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

// With generics — preserves type
function identity<T>(arg: T): T {
    return arg;
}

// Usage
const num = identity<number>(42);      // Type: number
const str = identity<string>('hello'); // Type: string
const inferred = identity([1, 2, 3]);  // Type: number[] (inferred)

Generic Interfaces

interface ApiResponse<T> {
    data: T;
    status: number;
    error?: string;
}

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

const userResponse: ApiResponse<User> = {
    data: { id: '1', name: 'Alice' },
    status: 200
};

const listResponse: ApiResponse<User[]> = {
    data: [{ id: '1', name: 'Alice' }],
    status: 200
};

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];
    }
    
    size(): number {
        return this.items.length;
    }
}

// Usage
const stringQueue = new Queue<string>();
stringQueue.enqueue('first');
stringQueue.enqueue('second');

const numberQueue = new Queue<number>();
numberQueue.enqueue(1);
numberQueue.enqueue(2);

Real-World Patterns

1. Repository Pattern with Generics

interface Entity {
    id: string;
    createdAt: Date;
    updatedAt: Date;
}

class Repository<T extends Entity> {
    private items: Map<string, T> = new Map();
    
    create(item: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): T {
        const now = new Date();
        const newItem = {
            ...item,
            id: crypto.randomUUID(),
            createdAt: now,
            updatedAt: now
        } as T;
        
        this.items.set(newItem.id, newItem);
        return newItem;
    }
    
    findById(id: string): T | undefined {
        return this.items.get(id);
    }
    
    findAll(): T[] {
        return Array.from(this.items.values());
    }
    
    update(id: string, updates: Partial<Omit<T, 'id' | 'createdAt'>>): T | undefined {
        const existing = this.items.get(id);
        if (!existing) return undefined;
        
        const updated = {
            ...existing,
            ...updates,
            updatedAt: new Date()
        };
        
        this.items.set(id, updated);
        return updated;
    }
    
    delete(id: string): boolean {
        return this.items.delete(id);
    }
}

// Usage
interface Product extends Entity {
    name: string;
    price: number;
    inStock: boolean;
}

const productRepo = new Repository<Product>();

const product = productRepo.create({
    name: 'Laptop',
    price: 999,
    inStock: true
});

const updated = productRepo.update(product.id, { price: 899 });

2. API Client with Type Safety

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

interface RequestConfig<TBody = unknown> {
    method: HttpMethod;
    url: string;
    body?: TBody;
    headers?: Record<string, string>;
}

interface ApiResponse<T> {
    data: T;
    status: number;
    headers: Headers;
}

class ApiClient {
    private baseUrl: string;
    
    constructor(baseUrl: string) {
        this.baseUrl = baseUrl;
    }
    
    async request<TResponse, TBody = unknown>(
        config: RequestConfig<TBody>
    ): Promise<ApiResponse<TResponse>> {
        const response = await fetch(`${this.baseUrl}${config.url}`, {
            method: config.method,
            headers: {
                'Content-Type': 'application/json',
                ...config.headers
            },
            body: config.body ? JSON.stringify(config.body) : undefined
        });
        
        const data = await response.json();
        
        return {
            data,
            status: response.status,
            headers: response.headers
        };
    }
    
    // Convenience methods
    get<T>(url: string) {
        return this.request<T>({ method: 'GET', url });
    }
    
    post<T, B>(url: string, body: B) {
        return this.request<T, B>({ method: 'POST', url, body });
    }
    
    put<T, B>(url: string, body: B) {
        return this.request<T, B>({ method: 'PUT', url, body });
    }
    
    patch<T, B>(url: string, body: B) {
        return this.request<T, B>({ method: 'PATCH', url, body });
    }
    
    delete<T>(url: string) {
        return this.request<T>({ method: 'DELETE', url });
    }
}

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

interface CreateUserDto {
    email: string;
    name: string;
}

const api = new ApiClient('https://api.example.com');

// Fully typed API calls
const { data: users } = await api.get<User[]>('/users');
const { data: newUser } = await api.post<User, CreateUserDto>('/users', {
    email: 'alice@example.com',
    name: 'Alice'
});
const { data: updated } = await api.patch<User, Partial<User>>(
    `/users/${newUser.id}`,
    { name: 'Alice Smith' }
);

3. Event Emitter with Type Safety

type EventMap = Record<string, any>;

type EventKey<T extends EventMap> = string & keyof T;
type EventHandler<T> = (payload: T) => void;

class TypedEventEmitter<Events extends EventMap> {
    private handlers: {
        [K in keyof Events]?: EventHandler<Events[K]>[]
    } = {};
    
    on<K extends EventKey<Events>>(
        event: K,
        handler: EventHandler<Events[K]>
    ): () => void {
        if (!this.handlers[event]) {
            this.handlers[event] = [];
        }
        this.handlers[event]!.push(handler);
        
        // Return unsubscribe function
        return () => this.off(event, handler);
    }
    
    off<K extends EventKey<Events>>(
        event: K,
        handler: EventHandler<Events[K]>
    ): void {
        const handlers = this.handlers[event];
        if (handlers) {
            const index = handlers.indexOf(handler);
            if (index > -1) {
                handlers.splice(index, 1);
            }
        }
    }
    
    emit<K extends EventKey<Events>>(event: K, payload: Events[K]): void {
        const handlers = this.handlers[event];
        if (handlers) {
            handlers.forEach(h => h(payload));
        }
    }
}

// Usage
interface MyEvents {
    'user:login': { userId: string; timestamp: Date };
    'user:logout': { userId: string };
    'data:update': { table: string; records: number };
    'error': { message: string; code: number };
}

const emitter = new TypedEventEmitter<MyEvents>();

// Type-safe event handling
emitter.on('user:login', ({ userId, timestamp }) => {
    console.log(`User ${userId} logged in at ${timestamp}`);
});

emitter.on('data:update', ({ table, records }) => {
    console.log(`${table} updated with ${records} records`);
});

// Type error: wrong payload shape
// emitter.emit('user:login', { userId: '1' }); // Error: missing timestamp

// Correct usage
emitter.emit('user:login', {
    userId: 'user-123',
    timestamp: new Date()
});

Advanced Patterns

4. Constrained Generics

// Only accept types with an 'id' property
interface HasId {
    id: string;
}

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

// Multiple constraints
interface HasTimestamps {
    createdAt: Date;
    updatedAt: Date;
}

function sortByDate<T extends HasTimestamps>(items: T[]): T[] {
    return [...items].sort((a, b) => 
        b.createdAt.getTime() - a.createdAt.getTime()
    );
}

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

const user = { id: '1', name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // Type: string
const age = getProperty(user, 'age');   // Type: number
// const wrong = getProperty(user, 'email'); // Error: 'email' doesn't exist

5. Conditional Types

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

type A = IsString<'hello'>;  // true
type B = IsString<123>;      // false

// Extract type from array
type ElementType<T> = T extends (infer E)[] ? E : T;

type Numbers = ElementType<number[]>;  // number
type StringOrNum = ElementType<string>; // string

// Flatten nested arrays
type Flatten<T> = T extends (infer E)[] ? Flatten<E> : T;

type DeepArray = Flatten<string[][][]>; // string

// Create nullable version
type Nullable<T> = T | null;

type NullableString = Nullable<string>; // string | null

// Non-null type
type NonNullable<T> = T extends null | undefined ? never : T;

type DefinitelyString = NonNullable<string | null>; // string

6. 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];
};

// Remove readonly
type Mutable<T> = {
    -readonly [P in keyof T]: T[P];
};

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

// Omit specific properties
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// Record type
type Record<K extends keyof any, T> = {
    [P in K]: T;
};

// Usage examples
interface User {
    id: string;
    email: string;
    name: string;
    password: string;
    createdAt: Date;
}

// API response doesn't include password
type UserResponse = Omit<User, 'password'>;

// Form input only needs name and email
type UserFormInput = Pick<User, 'name' | 'email'>;

// Update can have partial fields
type UserUpdate = Partial<Omit<User, 'id' | 'createdAt'>>;

// Readonly for immutable state
type ImmutableUser = Readonly<User>;

7. Template Literal Types

// Create event names from actions
type EventName<T extends string> = `on${Capitalize<T>}`;

type ClickEvent = EventName<'click'>; // 'onClick'
type HoverEvent = EventName<'hover'>; // 'onHover'

// CSS property types
type CSSProperty = 'margin' | 'padding' | 'border';
type CSSDirection = 'top' | 'right' | 'bottom' | 'left';

type CSSPropertyWithDirection = `${CSSProperty}${Capitalize<CSSDirection>}`;
// 'marginTop' | 'marginRight' | 'marginBottom' | 'marginLeft'
// 'paddingTop' | 'paddingRight' | ... etc

// Route parameters
type Route<Path extends string> = 
    Path extends `${infer Start}/:${infer Param}/${infer Rest}`
        ? { [K in Param]: string } & Route<`${Start}/${Rest}`>
        : Path extends `${string}/:${infer Param}`
            ? { [K in Param]: string }
            : {};

// Usage
 type UserRoute = Route<'/users/:userId/posts/:postId'>;
// { userId: string; postId: string }

8. Infer with Generics

// Extract return type
type ReturnType<T extends (...args: any[]) => any> = 
    T extends (...args: any[]) => infer R ? R : never;

function createUser() {
    return { id: '1', name: 'Alice' };
}

type User = ReturnType<typeof createUser>;
// { id: string; name: string }

// Extract parameters
type Parameters<T extends (...args: any[]) => any> =
    T extends (...args: infer P) => any ? P : never;

function updateUser(id: string, data: Partial<User>) {
    return { ...data, id };
}

type UpdateUserParams = Parameters<typeof updateUser>;
// [string, Partial<User>]

// Extract Promise type
type Awaited<T> = T extends Promise<infer U> ? U : T;

async function fetchUser(): Promise<User> {
    return { id: '1', name: 'Alice' };
}

type FetchedUser = Awaited<ReturnType<typeof fetchUser>>;
// User (not Promise<User>)

Utility Types Reference

TypePurposeExample
Partial<T>All properties optionalPartial<User>
Required<T>All properties requiredRequired<Config>
Readonly<T>All properties readonlyReadonly<State>
Pick<T, K>Select specific keysPick<User, 'id' | 'name'>
Omit<T, K>Remove specific keysOmit<User, 'password'>
Record<K, V>Dictionary typeRecord<string, User>
Exclude<T, U>Remove types from unionExclude<'a' | 'b', 'a'>
Extract<T, U>Extract types from unionExtract<'a' | 'b', 'a'>
NonNullable<T>Remove null/undefinedNonNullable<string | null>
ReturnType<T>Function return typeReturnType<typeof fn>
Parameters<T>Function parametersParameters<typeof fn>
Awaited<T>Unwrap PromiseAwaited<Promise<User>>

Common Patterns

Factory Pattern

interface EntityConstructor<T extends HasId> {
    new (data: Omit<T, 'id'>): T;
}

function createEntity<T extends HasId>(
    Constructor: EntityConstructor<T>,
    data: Omit<T, 'id'>
): T {
    return new Constructor(data);
}

class Product implements HasId {
    id: string;
    name: string;
    price: number;
    
    constructor(data: Omit<Product, 'id'>) {
        this.id = crypto.randomUUID();
        this.name = data.name;
        this.price = data.price;
    }
}

const product = createEntity(Product, { name: 'Laptop', price: 999 });

Builder Pattern

class QueryBuilder<T extends Record<string, any>> {
    private filters: Partial<T> = {};
    private sortKey: keyof T | null = null;
    private sortDirection: 'asc' | 'desc' = 'asc';
    
    where<K extends keyof T>(key: K, value: T[K]): this {
        this.filters[key] = value;
        return this;
    }
    
    orderBy(key: keyof T, direction: 'asc' | 'desc' = 'asc'): this {
        this.sortKey = key;
        this.sortDirection = direction;
        return this;
    }
    
    build(): { filters: Partial<T>; sort: { key: keyof T; direction: 'asc' | 'desc' } | null } {
        return {
            filters: this.filters,
            sort: this.sortKey ? { key: this.sortKey, direction: this.sortDirection } : null
        };
    }
}

interface User {
    id: string;
    name: string;
    age: number;
    active: boolean;
}

const query = new QueryBuilder<User>()
    .where('active', true)
    .where('age', 25)
    .orderBy('name', 'asc')
    .build();

Summary

  • Generics add flexibility while preserving type safety
  • Constraints (extends) ensure types have required properties
  • Conditional types create type-level logic
  • Mapped types transform object shapes
  • Utility types solve common transformation needs
  • Template literals create type-safe string patterns

Master these patterns and you’ll write TypeScript that’s both flexible and bulletproof.