TypeScript Advanced Types and Generics — Deep Dive 2024

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Why This Matters

Generics are what separate surface-level TypeScript from truly expressive, type-safe code. Without generics, you either write duplicated functions for every type or fall back to any — defeating the entire purpose of TypeScript. With generics, you encode the relationship between inputs and outputs at the type level, letting the compiler verify correctness across the entire call chain.

For backend engineers building reusable libraries, ORMs, HTTP clients, or service layers, generics are the primary tool. Understanding them deeply — including constraints, inference, and conditional types — is the difference between writing TypeScript and writing TypeScript well.

Advanced generics also enable meta-programming patterns that eliminate boilerplate. Instead of writing a hundred similar typed functions, you write one generic that handles all cases with full type safety.

Generic Functions and Inference

TypeScript infers generic type parameters from usage, so you rarely need to specify them explicitly.

// Basic generic — T is inferred from the argument
function wrap<T>(value: T): { value: T; timestamp: Date } {
  return { value, timestamp: new Date() };
}
 
const wrapped = wrap('hello');        // { value: string; timestamp: Date }
const wrappedNum = wrap(42);          // { value: number; timestamp: Date }
const wrappedUser = wrap({ id: 1 });  // { value: { id: number }; timestamp: Date }
 
// Multiple type parameters
function transform<Input, Output>(
  input: Input,
  fn: (value: Input) => Output
): Output {
  return fn(input);
}
 
const length = transform('hello world', (s) => s.length); // number
const upper = transform(['a', 'b'], (arr) => arr.join(',')); // string

Generic Constraints

Constraints restrict what types a generic can accept, enabling access to shared properties.

// Constrain T to objects that have an id field
function findById<T extends { id: string }>(items: T[], id: string): T | undefined {
  return items.find((item) => item.id === id);
}
 
interface User { id: string; name: string; email: string }
interface Order { id: string; total: number; userId: string }
 
const users: User[] = [{ id: '1', name: 'Alice', email: 'alice@example.com' }];
const user = findById(users, '1');   // User | undefined
 
// keyof constraint — access known property keys
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const name = getProperty({ id: 1, name: 'Alice' }, 'name');  // string
// getProperty({ id: 1, name: 'Alice' }, 'age');  // Error: 'age' not in type

Generic Classes and Repositories

interface Entity {
  id: string;
  createdAt: Date;
  updatedAt: Date;
}
 
class Repository<T extends Entity> {
  private store: Map<string, T> = new Map();
 
  async create(entity: Omit<T, 'id' | 'createdAt' | 'updatedAt'>): Promise<T> {
    const now = new Date();
    const full = {
      ...entity,
      id: crypto.randomUUID(),
      createdAt: now,
      updatedAt: now,
    } as T;
    this.store.set(full.id, full);
    return full;
  }
 
  async findById(id: string): Promise<T | null> {
    return this.store.get(id) ?? null;
  }
 
  async update(id: string, patch: Partial<Omit<T, 'id' | 'createdAt'>>): Promise<T | null> {
    const existing = this.store.get(id);
    if (!existing) return null;
    const updated = { ...existing, ...patch, updatedAt: new Date() };
    this.store.set(id, updated);
    return updated;
  }
 
  async findAll(): Promise<T[]> {
    return Array.from(this.store.values());
  }
}
 
interface Product extends Entity {
  name: string;
  price: number;
  stock: number;
}
 
const productRepo = new Repository<Product>();
// All methods are fully typed for Product

Conditional Types

Conditional types let you choose between types based on a condition, enabling expressive type-level logic.

// Basic conditional type
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>;  // true
type B = IsArray<string>;    // false
 
// Extract the element type from an array
type ElementType<T> = T extends (infer E)[] ? E : T;
type StrElem = ElementType<string[]>;   // string
type NumSelf = ElementType<number>;     // number
 
// Unwrap a Promise
type Awaited_<T> = T extends Promise<infer R> ? Awaited_<R> : T;
type Resolved = Awaited_<Promise<Promise<string>>>;  // string
 
// Distribute over unions
type ToArray<T> = T extends any ? T[] : never;
type StrOrNumArr = ToArray<string | number>;  // string[] | number[]

Mapped Types with Generics

// Make every property a getter function
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Config {
  host: string;
  port: number;
  debug: boolean;
}
 
type ConfigGetters = Getters<Config>;
// {
//   getHost: () => string;
//   getPort: () => number;
//   getDebug: () => boolean;
// }
 
// Deeply partial type
type DeepPartial<T> = T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> }
  : T;
 
interface AppConfig {
  server: { host: string; port: number };
  database: { url: string; pool: { min: number; max: number } };
}
 
const partialConfig: DeepPartial<AppConfig> = {
  database: { pool: { max: 10 } }, // all other fields optional
};

Advanced Inference Patterns

// Infer function return type
type ReturnOf<T extends (...args: any[]) => any> = T extends (...args: any[]) => infer R ? R : never;
 
async function fetchUser(id: string) {
  return { id, name: 'Alice', email: 'alice@example.com' };
}
 
type UserData = ReturnOf<typeof fetchUser>;  // Promise<{ id: string; name: string; email: string }>
 
// Infer tuple element types
type FirstElement<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type Head = FirstElement<[string, number, boolean]>;  // string
 
// Builder pattern with generics
type BuilderState<T> = {
  [K in keyof T]?: T[K];
};
 
class Builder<T extends object> {
  private state: BuilderState<T> = {};
 
  set<K extends keyof T>(key: K, value: T[K]): this {
    this.state[key] = value;
    return this;
  }
 
  build(): T {
    return this.state as T;
  }
}

Common Mistakes

  • Over-constraining generics — use the minimum constraint needed
  • Using any[] as a constraint when unknown[] is safer
  • Not leveraging type inference — specifying type params when TypeScript can infer them
  • Ignoring the distributive nature of conditional types over unions
  • Combining too many generics in one function making it unreadable

Best Practices

  • Start with the simplest generic that works and add constraints only when needed
  • Name generic parameters meaningfully: TEntity, TKey, TValue over single letters for complex types
  • Use infer inside conditional types to extract nested type information
  • Prefer built-in utility types (Partial, Required, Pick, Omit) before writing custom mapped types
  • Test complex generic types with type aliases and check the inferred output in your IDE

Key Takeaways

  • TypeScript infers generic parameters from usage — explicit annotation is rarely needed
  • extends on a generic creates a constraint that restricts accepted types
  • keyof T combined with T[K] enables type-safe property access patterns
  • Conditional types use infer to extract type information from complex shapes
  • Mapped types iterate over keyof T to transform every property systematically
  • Distributive conditional types apply automatically when the input is a union type
  • Generic classes power repository, service, and builder patterns without code duplication
  • DeepPartial, DeepReadonly, and similar recursive types extend built-in utilities for real-world needs

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading