TypeScript Generics: Complete Guide with Constraints and Inference

A practical guide to writing reusable, type-safe abstractions in TypeScript using generics — covering generic functions, constraints, keyof patterns, and the inference rules that decide your type arguments.

Generics are the feature that lets you write a function or data structure once and have it stay precisely typed for every caller. Without them you are forced to choose between duplicating code for each type, or falling back to any and discarding the guarantees that made you adopt TypeScript in the first place.

This guide builds generics up from the problem they solve, then works through constraints, multiple type parameters, generic classes, and the inference behaviour that most often surprises developers in production code.

Why any Is Not an Abstraction

Consider a function that returns the first element of an array. Typed with any, it compiles — but every call site loses the element type, and the compiler can no longer catch mistakes downstream.

TypeScript
Using any erases type information at the call site.
function firstAny(items: any[]): any {
  return items[0];
}

const n = firstAny([1, 2, 3]);
// n is `any` — this typo compiles and crashes at runtime
console.log(n.toFixed(2).toUpperCase());

A generic type parameter instead acts as a placeholder that is filled in per call. The function body stays generic, while each call site gets a concrete type.

TypeScript
A single type parameter preserves the element type for every caller.
function first<T>(items: T[]): T | undefined {
  return items[0];
}

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

// Error: Property 'toUpperCase' does not exist on type 'number'.
// num?.toUpperCase();
Note: Returning T | undefined rather than T is deliberate: indexing an array can always yield nothing. Enabling noUncheckedIndexedAccess in tsconfig makes the compiler enforce this for you.

Restricting What a Type Parameter Accepts

An unconstrained T could be anything, so the compiler permits only operations valid for every possible type. The moment the body needs a property, the type parameter needs a constraint.

TypeScript
Constraining T to shapes that have a length property.
interface HasLength {
  length: number;
}

function longest<T extends HasLength>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("typescript", "js");      // string
longest([1, 2, 3], [4, 5]);       // number[]

// Error: Argument of type 'number' is not assignable to
// parameter of type 'HasLength'.
// longest(10, 20);

The constraint is a contract in both directions: it tells the compiler which members are safe to use inside the body, and it rejects callers that supply an incompatible type.

Constraining Keys with keyof

Pairing a constraint with keyof produces a property getter that is checked against the actual object type, so a misspelled key is a compile error rather than an undefined at runtime.

TypeScript
A type-safe property accessor using two related type parameters.
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 7, name: "Ada", active: true };

const id = getProp(user, "id");        // number
const name = getProp(user, "name");    // string

// Error: Argument of type '"nmae"' is not assignable to
// parameter of type '"id" | "name" | "active"'.
// getProp(user, "nmae");

Here K is constrained to the union of T's keys, and the return type T[K] is an indexed access type that resolves to whatever that specific property holds — not a union of all property types.

Reusable Container and Collection Types

Type parameters are not limited to functions. Interfaces, type aliases, and classes can all be parameterised, which is how you model API envelopes, result wrappers, and data structures without losing precision.

TypeScript
A generic API response envelope and a discriminated result type.
interface ApiResponse<TData> {
  data: TData;
  status: number;
  requestId: string;
}

// A discriminated union is the idiomatic way to model fallible operations
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function fetchUser(id: number): Promise<Result<ApiResponse<{ name: string }>>> {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
    return { ok: true, value: await res.json() };
  } catch (err) {
    return { ok: false, error: err as Error };
  }
}

const result = await fetchUser(7);
if (result.ok) {
  // Narrowed: `value` is available, `error` is not
  console.log(result.value.data.name);
}

Note the default type argument E = Error. Defaults let callers write Result<User> for the common case while still allowing Result<User, ValidationError> where a richer error type matters.

TypeScript
A generic class implementing a type-safe typed stack.
class Stack<T> {
  private 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;
  }
}

const stack = new Stack<string>();
stack.push("first");
stack.push("second");
const top = stack.pop();  // string | undefined

Explicit Arguments, Inference, and Literal Widening

In most code you never write the type argument — TypeScript infers it from the values you pass. Understanding when inference widens a literal type explains a large share of confusing generic errors.

TypeScript
Inference widens literals unless a constraint keeps them narrow.
function wrap<T>(value: T): T[] {
  return [value];
}

const a = wrap("hello");        // string[]  — literal widened
const b = wrap<"hello">("hello"); // "hello"[] — explicit argument

// A constraint to a primitive keeps the literal narrow
function keepLiteral<T extends string>(value: T): T {
  return value;
}
const c = keepLiteral("hello");  // "hello"  — not widened

The rule of thumb: an unconstrained T widens string and number literals to string and number, while T extends string signals that the literal itself is meaningful and should be preserved.

SignatureCallInferred T
f<T>(x: T)f("a")string
f<T extends string>(x: T)f("a")"a"
f<T>(x: T[])f([1, "a"])string | number
f<T>(x: readonly T[])f([1, 2] as const)1 | 2

Transforming Types at the Type Level

Generics compose with conditional types and mapped types, which is how library authors derive one type from another instead of maintaining two definitions that can drift apart.

TypeScript
Conditional types with infer, and a mapped type over keys.
// Conditional type: unwrap the resolved type of a Promise
type Awaitedish<T> = T extends Promise<infer U> ? U : T;

type A = Awaitedish<Promise<string>>;  // string
type B = Awaitedish<number>;           // number

// Mapped type: make every property optional and nullable
type Draft<T> = {
  [K in keyof T]?: T[K] | null;
};

interface Post {
  title: string;
  body: string;
  views: number;
}

type PostDraft = Draft<Post>;
// { title?: string | null; body?: string | null; views?: number | null }

// Key remapping: derive getter names from property names
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type PostGetters = Getters<Post>;
// { getTitle: () => string; getBody: () => string; getViews: () => number }

The infer keyword introduces a type variable captured during matching — it is what makes conditional types able to extract a type rather than merely test one. Key remapping with as lets a mapped type rename properties as it rewrites them.

• Use a type parameter whenever a type appears in more than one position in a signature.
• Add a constraint as soon as the body needs a specific property or key.
• Prefer inference at call sites; pass explicit type arguments only to keep a literal narrow.
• Reach for conditional and mapped types to derive related types instead of duplicating them.

Summary

Generics let a single definition serve many concrete types while keeping full compile-time checking — the core trade that separates TypeScript from untyped JavaScript with annotations bolted on.

Start with a plain type parameter, add extends constraints when the implementation needs guarantees, use keyof and indexed access types for property-level safety, and lean on conditional and mapped types to derive types rather than hand-maintain them.

Applied consistently, these patterns remove most any casts from a codebase and let the compiler catch interface drift before it reaches production.