TypeScript Advanced Types and Generics — Deep Dive 2024
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(',')); // stringGeneric 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 typeGeneric 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 ProductConditional 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 whenunknown[]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,TValueover single letters for complex types - Use
inferinside 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
typealiases and check the inferred output in your IDE
Key Takeaways
- TypeScript infers generic parameters from usage — explicit annotation is rarely needed
extendson a generic creates a constraint that restricts accepted typeskeyof Tcombined withT[K]enables type-safe property access patterns- Conditional types use
inferto extract type information from complex shapes - Mapped types iterate over
keyof Tto 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