TypeScriptGenerics Introduction

Generics Introduction

Generics are TypeScript's mechanism for writing code that works with many types while still being fully type-safe. Instead of committing to a specific type upfront, you write a type parameter — a placeholder that gets filled in when the code is used. Generics let you build reusable, flexible abstractions without sacrificing any type information.

The Problem Generics Solve

Without generics, you face an impossible choice: write identical functions for every type, or give up type safety by using any.

TS
// Approach 1: Duplicate for every type (tedious)
function firstNumber(arr: number[]): number | undefined { return arr[0]; }
function firstString(arr: string[]): string | undefined { return arr[0]; }
function firstBoolean(arr: boolean[]): boolean | undefined { return arr[0]; }

// Approach 2: Use any (no type safety — might as well use JavaScript)
function first(arr: any[]): any { return arr[0]; }
const val = first([1, 2, 3]);
val.toUpperCase(); // No error at compile time — but crashes at runtime!

// Approach 3: Generics — write once, type-safe always
function first<T>(arr: T[]): T | undefined {
  return arr[0];
}

const num = first([1, 2, 3]);     // T inferred as number → number | undefined
const str = first(["a", "b"]);    // T inferred as string → string | undefined
num?.toFixed(2);     // OK — TypeScript knows num is number
str?.toUpperCase();  // OK — TypeScript knows str is string
Success
Generics give you the reusability of any with the precision of specific types. TypeScript infers the type argument at the call site so you rarely need to supply it explicitly.
Type Parameter Syntax

Type parameters are declared inside angle brackets (&lt;&gt;) immediately after the function name (or class name, or type alias name). By convention, single letters like T, U, K, V are used for generic parameters, but any valid identifier works.

TS
// Single type parameter
function identity<T>(value: T): T {
  return value;
}

// Multiple type parameters
function pair<A, B>(first: A, second: B): [A, B] {
  return [first, second];
}

// More descriptive names (equally valid)
function transform<Input, Output>(
  value: Input,
  fn: (input: Input) => Output,
): Output {
  return fn(value);
}

// Usage — TypeScript infers all type parameters
const p = pair("hello", 42);       // [string, number]
const n = transform("123", Number); // number
Explicit vs. Inferred Type Arguments

TypeScript usually infers type arguments from the values you pass. You can supply them explicitly when inference is ambiguous or when you want to lock in a specific type.

TS
function createArray<T>(item: T, length: number): T[] {
  return Array.from({ length }, () => item);
}

// Inferred — TypeScript figures out T = string
const strs = createArray("x", 5);  // string[]

// Explicit — you tell TypeScript T = number
const nums = createArray<number>(0, 10); // number[]

// Sometimes inference needs help
function merge<T, U>(a: T, b: U): T & U {
  return { ...a, ...b } as T & U;
}

// Inferred from arguments
const result = merge({ name: "Alice" }, { age: 30 });
// result: { name: string } & { age: number }  → { name: string; age: number }
Tip
Explicit type arguments are useful when you want a wider type than TypeScript would infer. For example, createArray<string | number>("x", 3) produces (string | number)[] instead of string[].
Generic Interfaces

Interfaces can be generic too, letting you describe data structures that work across types.

TS
// A generic container
interface Box<T> {
  value: T;
  label: string;
}

const stringBox: Box<string> = { value: "hello", label: "greeting" };
const numberBox: Box<number> = { value: 42,      label: "answer" };

// Generic response wrapper (extremely common in real codebases)
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
  timestamp: string;
}

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

// Using the generic response for a specific payload
type UserResponse = ApiResponse<User>;
type UserListResponse = ApiResponse<User[]>;

function handleResponse(res: UserResponse) {
  console.log(res.data.name);  // fully typed
  console.log(res.status);     // number
}
Generic Type Aliases

TS
// Generic type alias
type Maybe<T> = T | null | undefined;

type MaybeString = Maybe<string>;  // string | null | undefined
type MaybeNumber = Maybe<number>;  // number | null | undefined

// Generic Result type (a poor man's Rust Result<T, E>)
type Result<T, E = Error> =
  | { ok: true;  value: T }
  | { ok: false; error: E };

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

const r = divide(10, 2);
if (r.ok) {
  console.log(r.value); // number
} else {
  console.error(r.error.message);
}

// Generic Pair
type Pair<T, U = T> = [T, U]; // U defaults to T
type NumberPair  = Pair<number>;          // [number, number]
type MixedPair   = Pair<string, boolean>; // [string, boolean]
Generic Constraints

By default, a generic type parameter T can be anything. Use extends to constrain T to a subset of types, unlocking properties and methods of the constraint inside the function.

TS
// Without constraint — T is unknown inside the function
function getLength<T>(value: T): number {
  // return value.length; // Error — T might not have .length
  return 0;
}

// With constraint — T must have a 'length' property
function getLength<T extends { length: number }>(value: T): number {
  return value.length; // OK — guaranteed by the constraint
}

getLength("hello");       // 5
getLength([1, 2, 3]);     // 3
getLength({ length: 10 }); // 10
// getLength(42);          // Error — number has no 'length'

// Constraint to an object type with a specific key
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", active: true };
const name = getProperty(user, "name");    // string
const id   = getProperty(user, "id");      // number
// getProperty(user, "missing");            // Error — not a key of user
Note
The pattern K extends keyof T is one of the most powerful constraint combinations in TypeScript. It guarantees that K is a valid key of T, making the return type T[K] fully precise.
Generic Classes

TS
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;
  }

  isEmpty(): boolean {
    return this.items.length === 0;
  }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop(); // number | undefined

const strStack = new Stack<string>();
strStack.push("hello");
strStack.push("world");
const word = strStack.peek(); // string | undefined
Default Type Parameters

Like function parameters, type parameters can have default values with = DefaultType.

TS
// E defaults to Error if not supplied
interface Result<T, E = Error> {
  data?: T;
  error?: E;
}

const ok: Result<User> = { data: { id: 1, name: "Alice", email: "a@b.com" } };
const fail: Result<User, string> = { error: "Not found" };

// Generic component props with defaults
interface ListProps<T = string> {
  items: T[];
  renderItem: (item: T) => string;
}

const stringList: ListProps = {
  items: ["a", "b"],
  renderItem: (item) => item,
};

const numberList: ListProps<number> = {
  items: [1, 2, 3],
  renderItem: (n) => n.toFixed(2),
};
Common Generic Utilities You Will Write

TS
// groupBy — group array items by a key
function groupBy<T, K extends PropertyKey>(
  items: T[],
  keySelector: (item: T) => K,
): Record<K, T[]> {
  return items.reduce(
    (acc, item) => {
      const key = keySelector(item);
      (acc[key] ??= []).push(item);
      return acc;
    },
    {} as Record<K, T[]>,
  );
}

const users = [
  { id: 1, role: "admin",  name: "Alice" },
  { id: 2, role: "editor", name: "Bob"   },
  { id: 3, role: "admin",  name: "Carol" },
];
const byRole = groupBy(users, (u) => u.role);
// byRole.admin  → [Alice, Carol]
// byRole.editor → [Bob]

// keyBy — convert array to object keyed by a property
function keyBy<T, K extends PropertyKey>(
  items: T[],
  keySelector: (item: T) => K,
): Record<K, T> {
  return items.reduce(
    (acc, item) => {
      acc[keySelector(item)] = item;
      return acc;
    },
    {} as Record<K, T>,
  );
}

const userMap = keyBy(users, (u) => u.id);
// userMap[1] → { id: 1, role: "admin", name: "Alice" }
Generics vs. any vs. unknown

Feature

any

unknown

Generic T

Type-safe

No

Yes (after narrowing)

Yes — always

Preserves input type

No

No

Yes

Can use methods/props

Yes (unsafe)

No (must narrow first)

Yes (if constrained)

Inferred at call site

N/A

N/A

Yes

Good for reuse

Poor

Poor

Excellent

Variance: Covariance and Contravariance

Generic types have a variance relationship to their type parameters — this determines when one generic type is assignable to another. Understanding variance helps you reason about generic function compatibility.

TS
// Covariant: if Dog extends Animal, then Box<Dog> is assignable to Box<Animal>
// (output/producer position — returning T)
type Producer<T> = () => T;

const dogProducer: Producer<Dog> = () => new Dog();
const animalProducer: Producer<Animal> = dogProducer; // OK — covariant

// Contravariant: Consumer<Animal> is assignable to Consumer<Dog>
// (input/consumer position — accepting T)
type Consumer<T> = (value: T) => void;

const animalConsumer: Consumer<Animal> = (a) => console.log(a.name);
const dogConsumer: Consumer<Dog> = animalConsumer; // OK — contravariant

// Invariant: both input and output — no substitution is safe
type Transform<T> = (value: T) => T;
// Transform<Dog> is NOT assignable to Transform<Animal> and vice versa

// TypeScript 4.7+ allows explicit variance annotations
type CovariantBox<out T>     = { get: () => T };
type ContravariantSink<in T> = { consume: (value: T) => void };
Note
In practice, you rarely need to think about variance explicitly. TypeScript infers it automatically. The annotations out T and in T are useful in complex generic hierarchies where TypeScript's inference is conservative.
Generics in React (Brief Preview)

TS
// Generic component props
interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (item: T) => string;
}

// The component is generic — works for any option type
function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
  return (
    <select
      value={String(value)}
      onChange={(e) => {
        const selected = options.find((o) => String(o) === e.target.value);
        if (selected !== undefined) onChange(selected);
      }}
    >
      {options.map((o, i) => (
        <option key={i} value={String(o)}>{getLabel(o)}</option>
      ))}
    </select>
  );
}

// Usage with different types — T is inferred from props
type Status = "active" | "inactive" | "pending";
<Select
  options={["active", "inactive", "pending"] as Status[]}
  value={"active"}
  onChange={(v) => console.log(v)} // v: Status
  getLabel={(s) => s.toUpperCase()}
/>
Summary
  • Generics let you write reusable, type-safe code without committing to a specific type

  • Type parameters are declared with angle brackets after the function, class, or type alias name

  • TypeScript infers type arguments from call-site values — explicit annotation is rarely needed

  • Generic interfaces and type aliases describe reusable data shapes like Box<T> and Result<T, E>

  • Constraints (extends) restrict a type parameter and unlock the constrained type inside the body

  • K extends keyof T is the key pattern for safe property access and lookup functions

  • Default type parameters provide fallbacks when no type argument is given

  • Variance determines when one generic type is assignable to another (covariant, contravariant, invariant)