Stackvora.tech

TypeScript generics demystified

Generics in TypeScript provide a powerful mechanism to create reusable components that can work with a variety of types while still maintaining type…

Generics in TypeScript provide a powerful mechanism to create reusable components that can work with a variety of types while still maintaining type safety. They allow you to write functions, classes, or interfaces that operate on a placeholder type, which is then specified by the consuming code. This approach enables greater flexibility and type inference, preventing costly runtime errors by catching type mismatches at compile time.

The Core Concept: Type Variables

At its heart, a generic introduces a "type variable" that acts as a placeholder for a concrete type. This variable is typically denoted by single uppercase letters like T (for Type), K (for Key), V (for Value), or U (for another Type). When you use a generic component, you provide the actual type that the type variable should represent.

Generic Functions: The Identity Function Example

A classic illustration is the identity function, which returns whatever is passed into it. Without generics, you might write:

function identityUntyped(arg: any): any {
  return arg;
}

function identitySpecificString(arg: string): string {
  return arg;
}

function identitySpecificNumber(arg: number): number {
  return arg;
}

The any type loses type information, meaning you can't rely on autocompletion or static analysis for the return value. The specific functions are not reusable for other types. With generics:

function identity<T>(arg: T): T {
  return arg;
}

let outputString = identity<string>("myString"); // Type of outputString is 'string'
let outputNumber = identity<number>(123);     // Type of outputNumber is 'number'
let outputBoolean = identity(true);             // Type inference works: outputBoolean is 'boolean'

Here, <T> declares T as a type variable. When calling identity<string>("myString"), T becomes string. TypeScript can also often infer the type, as shown with identity(true).

Generic Interfaces and Type Aliases

Generics aren't limited to functions. They're incredibly useful for interfaces and type aliases to create flexible data structures.

interface Box<T> {
  value: T;
}

type Result<T, E> = { success: true; data: T } | { success: false; error: E };

let stringBox: Box<string> = { value: "hello" };
let numberBox: Box<number> = { value: 123 };

let successResult: Result<{ id: string }, Error> = { success: true, data: { id: "abc" } };
let errorResult: Result<string[], string> = { success: false, error: "Operation failed" };

Generic Classes

Classes can also be generic, allowing them to operate on types specified at instantiation.

class GenericStack<T> {
  private elements: T[] = [];

  push(element: T): void {
    this.elements.push(element);
  }

  pop(): T | undefined {
    return this.elements.pop();
  }

  peek(): T | undefined {
    return this.elements[this.elements.length - 1];
  }

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

let numberStack = new GenericStack<number>();
numberStack.push(10);
numberStack.push(20);
console.log(numberStack.pop()); // 20 (type is number)

let stringStack = new GenericStack<string>();
stringStack.push("A");
stringStack.push("B");
console.log(stringStack.pop()); // "B" (type is string)

Type Constraints: Restricting Generic Types

Sometimes, you need generic functions or classes to operate on a specific set of types, not just any type. Type constraints allow you to restrict what types can be used for a type variable.

The extends Keyword

You use the extends keyword to define a constraint. For example, if you want a function that logs the length of an argument, that argument must have a .length property.

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length); // Now we know 'arg' has a .length property
  return arg;
}

loggingIdentity("hello");         // OK, string has a .length
loggingIdentity([1, 2, 3]);       // OK, array has a .length
// loggingIdentity(3);            // Error: Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.

Constraints can also be applied to other types. For instance, you might want a function that accesses a property by key, so the key must be a valid key of the object:

function getProperty<T, K extends keyof T>(obj: T, key: K) {
  return obj[key];
}

let user = { id: 1, name: "Alice", email: "alice@example.com" };

let userName = getProperty(user, "name"); // Type is 'string'
let userId = getProperty(user, "id");     // Type is 'number'
// let userAddress = getProperty(user, "address"); // Error: Argument of type '"address"' is not assignable to parameter of type '"id" | "name" | "email"'.

Here, K extends keyof T ensures that the second argument key must be one of the literal string types representing the keys of T.

Advanced Generic Patterns

Inferred Return Types with Generics

TypeScript's type inference is powerful with generics. Often, you don't need to explicitly specify the type arguments when calling a generic function because the compiler can figure it out from the arguments you pass.

function createPair<T1, T2>(v1: T1, v2: T2): [T1, T2] {
  return [v1, v2];
}

let pair = createPair("hello", 123); // type of 'pair' is [string, number]

Mapped Types with Generics

Mapped types build new types by transforming the properties of an existing type. Generics are fundamental here, allowing you to iterate over properties of a generic type variable.

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

type Partial<T> = {
  [P in keyof T]?: T[P];
};

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

type ReadonlyUser = Readonly<User>;
/*
Equivalent to:
{
  readonly id: number;
  readonly name: string;
}
*/

type PartialUser = Partial<User>;
/*
Equivalent to:
{
  id?: number;
  name?: string;
}
*/

The P in keyof T syntax iterates over all property keys P of the type T.

Conditional Types with Generics

Conditional types allow you to select one of two possible types based on a condition that checks the relationship between generic types. They use a form that looks like a conditional (ternary) operator: SomeType extends OtherType ? TrueType : FalseType.

type IsString<T> = T extends string ? "Yes" : "No";

type A = IsString<string>; // "Yes"
type B = IsString<number>; // "No"

type Flatten<T> = T extends any[] ? T[number] : T;

type Str = Flatten<string[]>;   // string
type Num = Flatten<number>;     // number
type Arr = Flatten<boolean[]>;  // boolean

A common powerful use case is to extract or exclude properties from a union or interface:

type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

type MyColors = "red" | "green" | "blue" | "yellow";
type PrimaryColors = Exclude<MyColors, "green" | "yellow">; // "red" | "blue"
type SecondaryColors = Extract<MyColors, "green" | "yellow">; // "green" | "yellow"

Exclude and Extract are built-in utility types in TypeScript (since 2.8), demonstrating the utility of this pattern.

Common Pitfalls

  • Over-constraining: Applying too many or overly specific constraints can make your generic type less reusable than intended. Strive for the minimal necessary constraint.
  • Losing type information with any: While generics aim to avoid any, it's easy to fall back on it if you're not careful, especially when dealing with complex or deeply nested generic types. Always prefer a more specific type variable or constraint.
  • Confusing type parameters with values: Remember that type parameters exist only at compile time for type checking. You cannot use a generic type parameter (e.g., T) directly as a value at runtime (e.g., new T() is usually an error unless T is constrained to a constructable type).
  • Incorrect inference: Sometimes, TypeScript's inference might not be what you expect, especially with complex generics or when chaining calls. In such cases, explicitly providing type arguments (e.g., myFunc<string>(arg)) can resolve the issue.
  • Generic functions for overloaded functions: If a function truly needs different logic based on argument types (and not just type safety), consider function overloads instead of overly complex generic types.

Back to the knowledge base · Ask the AI assistant