TypeScript Generics: From Basics to Advanced Patterns

Requires
TypeScript 5.0+
Difficulty
Intermediate
Published
Author
generics type parameters generic constraints conditional types mapped types infer keyword utility types template literal types variance generic defaults

Why generics exist

TypeScript's type system exists to prove that your code is correct before it runs. But types are most useful when they are precise — when they capture the actual relationship between inputs and outputs. Without generics, you quickly hit a wall: either you write overly general types that lose information, or you duplicate code for every concrete type you need.

Consider a function that returns the first element of an array. Without generics, there are only bad options:

TypeScript — the problem
// Option 1: any — loses all type information
function first(arr: any[]): any {
    return arr[0];
}
const n = first([1, 2, 3]);   // n: any — TypeScript has no idea it's a number
n.toUpperCase();              // no error at compile time — crashes at runtime

// Option 2: union — only works for types you anticipated
function firstStrOrNum(arr: (string | number)[]): string | number {
    return arr[0];
}

// Option 3: overloads — tedious duplication
function firstStr(arr: string[]): string  { return arr[0]; }
function firstNum(arr: number[]): number  { return arr[0]; }
// and again for boolean, Date, User, ...every type you ever need

Generics solve this by introducing a type parameter — a placeholder that gets filled in with the actual type at each call site. The compiler infers or checks the type parameter, and threads it through the function to preserve the relationship between the input type and the output type:

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

const n = first([1, 2, 3]);         // n: number | undefined  
const s = first(["a", "b"]);         // s: string | undefined  
const d = first([new Date()]);      // d: Date | undefined    

n?.toUpperCase();   // error: Property 'toUpperCase' does not exist on type 'number'
s?.toUpperCase();   // OK — TypeScript knows s is string | undefined

One function, all types, full type safety. The type parameter T captures whatever array element type is passed in, and the return type T | undefined says "you get back the same type you put in, or nothing."

Generic functions

Type parameters are declared in angle brackets (<T>) between the function name and the parameter list. TypeScript usually infers them from the arguments — you rarely need to write them explicitly at call sites:

TypeScript
// Identity: returns whatever you pass in, same type
function identity<T>(value: T): T {
    return value;
}

// TypeScript infers T from the argument
const a = identity(42);          // T inferred as number
const b = identity("hello");     // T inferred as string
const c = identity(true);        // T inferred as boolean

// Explicit type argument when inference cannot determine it
const d = identity<number>(42);   // explicit: redundant here, but valid

// Wrapping a value in an array
function wrap<T>(value: T): T[] {
    return [value];
}

const nums = wrap(5);          // number[]
const strs = wrap("hello");     // string[]

// Reversing an array without losing element type
function reverse<T>(arr: T[]): T[] {
    return [...arr].reverse();
}

const reversed = reverse([1, 2, 3]);  // number[]

Arrow function syntax

TypeScript
// Arrow function generics
const identity = <T>(value: T): T => value;

// In .tsx files, the parser may confuse  with JSX.
// Use a trailing comma or constraint to disambiguate:
const identity2 = <T,>(value: T): T => value;       // trailing comma
const identity3 = <T extends unknown>(value: T): T => value;  // constraint

// Generic type aliases for function types
type Transformer<T, U> = (input: T) => U;

const toString: Transformer<number, string> = (n) => String(n);
const double:   Transformer<number, number> = (n) => n * 2;

Multiple type parameters

A function or type can have any number of type parameters. Use meaningful single letters by convention (T, U, V), or descriptive names when the intent is not obvious:

TypeScript
// Swap: returns tuple with types flipped
function swap<A, B>(pair: [A, B]): [B, A] {
    return [pair[1], pair[0]];
}

const result = swap(["hello", 42]);  // [number, string]

// Map over an array, transforming element type
function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
    return arr.map(fn);
}

const lengths = mapArray(["cat", "elephant"], s => s.length);
// lengths: number[]  — TypeScript inferred T=string, U=number

// Object key-value lookup with precise types
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const user = { name: "Alice", age: 30, active: true };
const name   = getProperty(user, "name");    // string
const age    = getProperty(user, "age");     // number
const active = getProperty(user, "active");  // boolean
// getProperty(user, "missing");  // error: not a key of typeof user

Generic interfaces

Interfaces with type parameters describe the shape of generic data structures and API contracts. They are the primary tool for creating reusable typed containers:

TypeScript
// A generic API response wrapper
interface ApiResponse<T> {
    data:    T;
    status:  number;
    message: string;
    timestamp: string;
}

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

// TypeScript knows exactly what .data contains
const response: ApiResponse<User> = {
    data:      { id: 1, name: "Alice", email: "alice@example.com" },
    status:    200,
    message:   "OK",
    timestamp: "2025-03-22T10:00:00Z",
};

console.log(response.data.name);    // string — full autocompletion
console.log(response.data.missing);  // error: Property 'missing' does not exist
TypeScript — generic pagination and result types
// Paginated list — reusable for any entity
interface PaginatedResult<T> {
    items:      T[];
    total:      number;
    page:       number;
    pageSize:   number;
    totalPages: number;
}

// A Result type that can hold a value or an error
type Result<T, E = Error> =
    | { success: true;  value: T }
    | { success: false; error: E };

function divide(a: number, b: number): Result<number> {
    if (b === 0) return { success: false, error: new Error("Division by zero") };
    return { success: true, value: a / b };
}

const r = divide(10, 2);
if (r.success) {
    console.log(r.value);  // number — narrowed by the success discriminant
} else {
    console.error(r.error.message);
}

Generic classes

Classes take type parameters the same way functions do. The type parameter is available throughout the class body:

TypeScript
class Stack<T> {
    private readonly items: T[] = [];

    push(item: T): void      { this.items.push(item); }
    pop(): T | undefined       { return this.items.pop(); }
    peek(): T | undefined      { return this.items[this.items.length - 1]; }
    get size(): number        { return this.items.length; }
    isEmpty(): boolean        { return this.items.length === 0; }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
numStack.push("oops");   // error: Argument of type 'string' is not assignable to 'number'

const strStack = new Stack<string>();
strStack.push("hello");
const top = strStack.peek();   // string | undefined — correct type
TypeScript — generic repository pattern
interface Entity {
    id: number;
}

class InMemoryRepository<T extends Entity> {
    private store = new Map<number, T>();

    save(entity: T): T {
        this.store.set(entity.id, entity);
        return entity;
    }

    findById(id: number): T | undefined {
        return this.store.get(id);
    }

    findAll(): T[] {
        return Array.from(this.store.values());
    }

    delete(id: number): boolean {
        return this.store.delete(id);
    }
}

interface Product extends Entity { name: string; price: number; }
const repo = new InMemoryRepository<Product>();
repo.save({ id: 1, name: "Keyboard", price: 79.99 });
const p = repo.findById(1);  // Product | undefined

Constraints with extends

By default, a type parameter T can be anything. You constrain it with extends to restrict what types can be substituted. The constraint tells TypeScript what operations are available on T values inside the function:

TypeScript
// Without constraint: T could be anything, so .length doesn't exist
function logLength<T>(value: T): void {
    console.log(value.length);  // error: Property 'length' does not exist on type 'T'
}

// With constraint: T must have a length property
interface HasLength { length: number; }

function logLength<T extends HasLength>(value: T): T {
    console.log(value.length);  // OK — T is guaranteed to have .length
    return value;               // still returns T, not just HasLength
}

logLength("hello");     // OK — string has .length
logLength([1, 2, 3]);  // OK — array has .length
logLength(new Set());  // error: Set has .size, not .length

// Constrain to object types (non-primitive)
function clone<T extends object>(obj: T): T {
    return { ...obj };
}

// Constrain to types with a specific constructor
function createInstance<T>(ctor: new() => T): T {
    return new ctor();
}

class Greeter { greet() { return "hello"; } }
const g = createInstance(Greeter);  // Greeter

keyof and property access constraints

keyof T produces a union of all the keys of type T. Combined with T[K] (indexed access types), it enables precisely typed property lookups where both the key and value types are known:

TypeScript
type Point = { x: number; y: number; label: string };

// keyof Point = "x" | "y" | "label"
type PointKeys = keyof Point;  // "x" | "y" | "label"

// T[K] is the type of property K on object T
type XType = Point["x"];      // number
type LabelType = Point["label"];  // string

// Generic lookup: K must be a valid key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

const p: Point = { x: 1, y: 2, label: "origin" };
const xVal   = getProperty(p, "x");      // number
const lVal   = getProperty(p, "label");  // string
// getProperty(p, "z");  // error: "z" is not a key of Point

// Pick only specified keys from an object
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
    const result = {} as Pick<T, K>;
    keys.forEach(k => { result[k] = obj[k]; });
    return result;
}

const coords = pick(p, ["x", "y"]);   // { x: number; y: number }

Default type parameters

Like function parameters can have defaults, type parameters can too. A default is used when the type argument is not provided and cannot be inferred:

TypeScript
// E defaults to Error if not specified
type Result<T, E = Error> =
    | { ok: true;  value: T }
    | { ok: false; error: E };

type StringResult  = Result<string>;          // E defaults to Error
type CustomResult  = Result<number, string>;  // E is string

// Generic component props with defaults
interface SelectProps<T = string> {
    options: T[];
    value:   T | null;
    onChange: (value: T) => void;
    getLabel?: (option: T) => string;
}

// String select — uses default T = string
const props1: SelectProps = {
    options:  ["a", "b", "c"],
    value:    "a",
    onChange: (v) => console.log(v),
};

// User select — explicit T
interface User { id: number; name: string; }
const props2: SelectProps<User> = {
    options:  [{ id: 1, name: "Alice" }],
    value:    null,
    onChange: (user) => console.log(user.name),
    getLabel: (user) => user.name,
};

Conditional types

A conditional type selects between two types based on whether a type extends another — essentially an if/else at the type level. The syntax is T extends U ? TrueType : FalseType:

TypeScript
// IsString: true if T is string, false otherwise
type IsString<T> = T extends string ? "yes" : "no";

type A = IsString<string>;   // "yes"
type B = IsString<number>;   // "no"
type C = IsString<boolean>;  // "no"

// Flatten: if T is an array, unwrap one level
type Flatten<T> = T extends any[] ? T[number] : T;

type Flat1 = Flatten<string[]>;   // string
type Flat2 = Flatten<number>;     // number
type Flat3 = Flatten<Date[]>;      // Date

// NonNullable: remove null and undefined from T
type NonNullable2<T> = T extends null | undefined ? never : T;

type D = NonNullable2<string | null>;             // string
type E = NonNullable2<number | undefined | null>;  // number

Distributive conditional types

When the checked type is a naked type parameter (not wrapped in anything), conditional types are distributive: they apply to each member of a union individually and union the results:

TypeScript
type ToArray<T> = T extends any ? T[] : never;

// Distributed: applied to each union member
type F = ToArray<string | number>;  // string[] | number[]

// To prevent distribution, wrap in a tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
type G = ToArrayNonDist<string | number>;  // (string | number)[]

The infer keyword

infer introduces a new type variable inside the extends clause of a conditional type. It captures a portion of a type being pattern-matched. This is how TypeScript extracts types from inside other types without needing to know them in advance:

TypeScript
// ReturnType: extract what a function returns
type ReturnType2<T extends (...args: any[]) => any> =
    T extends (...args: any[]) => infer R ? R : never;

type H = ReturnType2<() => string>;            // string
type I = ReturnType2<(n: number) => boolean>;   // boolean
type J = ReturnType2<typeof Math.random>;       // number

// Awaited: unwrap a Promise to its resolved type
type Awaited2<T> = T extends Promise<infer U> ? Awaited2<U> : T;

type K = Awaited2<Promise<string>>;                   // string
type L = Awaited2<Promise<Promise<number>>>;         // number (unwrapped twice)

// First argument type of a function
type FirstArg<T extends (...args: any[]) => any> =
    T extends (first: infer A, ...rest: any[]) => any ? A : never;

type M = FirstArg<(a: string, b: number) => void>;  // string

// Element type of an array
type ElementType<T> = T extends (infer E)[] ? E : never;

type N = ElementType<string[]>;   // string
type O = ElementType<Date[]>;      // Date

Mapped types

A mapped type iterates over the keys of a type and creates a new type by applying a transformation to each key. This is how all the built-in utility types like Partial, Required, Readonly, and Record are implemented:

TypeScript
// Syntax: [K in keyof T]: transformation

// Make every property optional (how Partial works)
type MyPartial<T> = {
    [K in keyof T]?: T[K];
};

// Make every property required (how Required works)
type MyRequired<T> = {
    [K in keyof T]-?: T[K];  // -? removes optionality
};

// Make every property readonly
type MyReadonly<T> = {
    readonly [K in keyof T]: T[K];
};

// Remove readonly from every property
type Mutable<T> = {
    -readonly [K in keyof T]: T[K];  // -readonly removes it
};
TypeScript — practical mapped types
// Wrap every property value in a Promise
type Promisify<T> = {
    [K in keyof T]: Promise<T[K]>;
};

// A form state: each field has value, error, and touched flags
type FormState<T> = {
    [K in keyof T]: {
        value:   T[K];
        error:   string | null;
        touched: boolean;
    };
};

interface LoginForm { email: string; password: string; }

type LoginFormState = FormState<LoginForm>;
// {
//   email:    { value: string; error: string | null; touched: boolean };
//   password: { value: string; error: string | null; touched: boolean };
// }

// Key remapping with 'as' (TypeScript 4.1+)
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type PersonGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }

Template literal types

TypeScript 4.1 introduced template literal types — the type-level equivalent of JavaScript template literals. They let you construct string union types by combining other string types:

TypeScript 4.1+
type EventName = "click" | "focus" | "blur";
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur"

type CSSProperty = "margin" | "padding";
type Side = "Top" | "Right" | "Bottom" | "Left";
type CSSLonghand = `${CSSProperty}${Side}`;
// "marginTop" | "marginRight" | "marginBottom" | "marginLeft" |
// "paddingTop" | "paddingRight" | ...etc (8 types)

// Using template literal types in a generic
type EventMap<T extends string> = {
    [K in T as `on${Capitalize<K>}`]: () => void;
};

type MouseEvents = EventMap<"click" | "mousedown" | "mouseup">;
// { onClick: () => void; onMousedown: () => void; onMouseup: () => void }
TypeScript — extracting parts of a string type
// Extract the route parameters from a route string
type RouteParams<T extends string> =
    T extends `${string}:${infer Param}/${infer Rest}`
        ? Param | RouteParams<`/${Rest}`>
    : T extends `${string}:${infer Param}`
        ? Param
    : never;

type Params = RouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"

Built-in utility types

TypeScript ships with a comprehensive set of utility types — all implemented using the generic patterns shown above. Here are the most commonly used:

TypeScript — object shape utilities
interface User {
    id:        number;
    name:      string;
    email:     string;
    createdAt: Date;
}

// Partial: all properties optional
type UserPatch = Partial<User>;
// { id?: number; name?: string; email?: string; createdAt?: Date }

// Required: all properties required (removes optionality)
type FullUser = Required<Partial<User>>;

// Pick: keep only specified keys
type UserSummary = Pick<User, "id" | "name">;
// { id: number; name: string }

// Omit: remove specified keys
type UserWithoutId = Omit<User, "id">;
// { name: string; email: string; createdAt: Date }

// Readonly: all properties readonly
type ImmutableUser = Readonly<User>;

// Record: object with specific key and value types
type RolePermissions = Record<"admin" | "user" | "guest", string[]>;
TypeScript — function and union utilities
function fetchUser(id: number): Promise<User> { return Promise.resolve({} as User); }

// ReturnType: extract return type of a function
type FetchResult = ReturnType<typeof fetchUser>;         // Promise

// Awaited: unwrap Promise to its resolved type
type ResolvedUser = Awaited<ReturnType<typeof fetchUser>>; // User

// Parameters: extract function parameters as a tuple
type FetchArgs = Parameters<typeof fetchUser>;  // [id: number]

// NonNullable: remove null and undefined
type P = NonNullable<string | null | undefined>;  // string

// Extract: keep members of T that extend U
type Q = Extract<string | number | boolean, number | boolean>;  // number | boolean

// Exclude: remove members of T that extend U
type R = Exclude<string | number | boolean, number | boolean>;  // string

Real-world generic patterns

Generic fetch wrapper with error handling

TypeScript
type ApiResult<T> =
    | { success: true;  data: T }
    | { success: false; error: string; status: number };

async function apiFetch<T>(url: string): Promise<ApiResult<T>> {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            return { success: false, error: response.statusText, status: response.status };
        }
        const data: T = await response.json();
        return { success: true, data };
    } catch (err) {
        return { success: false, error: String(err), status: 0 };
    }
}

// Caller gets full type safety for the data
const result = await apiFetch<User>("/api/users/1");
if (result.success) {
    console.log(result.data.name);  // data: User — full autocompletion
} else {
    console.error(result.error, result.status);
}

Generic event emitter

TypeScript
type EventMap = Record<string, unknown>;

class TypedEmitter<Events extends EventMap> {
    private listeners = new Map<keyof Events, ((data: unknown) => void)[]>();

    on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
        const handlers = this.listeners.get(event) ?? [];
        handlers.push(listener as (data: unknown) => void);
        this.listeners.set(event, handlers);
        return this;
    }

    emit<K extends keyof Events>(event: K, data: Events[K]): void {
        (this.listeners.get(event) ?? []).forEach(fn => fn(data));
    }
}

// Define your event map — event names and their payload types
interface AppEvents {
    userLoggedIn:  { userId: number; timestamp: Date };
    orderPlaced:   { orderId: string; total: number };
    errorOccurred: Error;
}

const emitter = new TypedEmitter<AppEvents>();

// TypeScript knows the payload type for each event
emitter.on("userLoggedIn", ({ userId, timestamp }) => {
    console.log(`User ${userId} logged in at ${timestamp}`);
});

emitter.emit("orderPlaced", { orderId: "ORD-001", total: 99.99 });
emitter.emit("orderPlaced", { orderId: 123 });   // error: orderId must be string

Builder pattern with generics

TypeScript — type-safe query builder
class QueryBuilder<T extends object> {
    private conditions: string[] = [];
    private selectedFields: (keyof T)[] = [];
    private limitValue?: number;

    select<K extends keyof T>(...fields: K[]): QueryBuilder<Pick<T, K>> {
        this.selectedFields = fields as (keyof T)[];
        return this as unknown as QueryBuilder<Pick<T, K>>;
    }

    where(condition: string): this {
        this.conditions.push(condition);
        return this;
    }

    limit(n: number): this {
        this.limitValue = n;
        return this;
    }

    build(): string {
        const fields = this.selectedFields.length ? this.selectedFields.join(", ") : "*";
        const where  = this.conditions.length ? ` WHERE ${this.conditions.join(" AND ")}` : "";
        const limit  = this.limitValue !== undefined ? ` LIMIT ${this.limitValue}` : "";
        return `SELECT ${fields}${where}${limit}`;
    }
}

const query = new QueryBuilder<User>()
    .select("id", "name")
    .where("age > 18")
    .limit(10)
    .build();
// "SELECT id, name WHERE age > 18 LIMIT 10"

Frequently Asked Questions

What is the difference between T extends object and T extends {} in TypeScript?
T extends object constrains T to non-primitive types — objects, arrays, and functions. T extends {} (or T extends NonNullable<unknown>) constrains T to any non-null, non-undefined value, including primitives like string and number. Use T extends object when you need to work with an object's properties; use T extends {} or T extends NonNullable<unknown> when you want to exclude null and undefined but allow primitives.
When should I use a generic type parameter versus unknown?
Use a generic type parameter T when the relationship between input and output types must be preserved. A function that returns what it receives should use T. Use unknown when you accept arbitrary input and want to force the caller to narrow it before use — for example when parsing JSON or receiving data from an external API. The key difference: T threads the specific type through the call; unknown forces the consumer to prove what the type is before using it.
What does the infer keyword do?
infer introduces a new type variable inside a conditional type's extends clause. It captures a portion of a type being pattern-matched, letting you extract sub-types without knowing them in advance. For example, ReturnType<T> uses infer R in the pattern "T extends (...args: any[]) => infer R" to capture whatever R the function returns. The infer variable is only available in the true branch of the conditional type.
What is the difference between a generic type and a union type?
A union type (string | number) says "this value is one of these specific types at runtime, but we don't know which until we check." A generic type (T) says "this value has a specific type that the caller decides, and the same T is consistent throughout the function." A function taking string | number can return either; a generic function taking T always returns the same T the caller passed in.