Stackvora.tech

TypeScript Generics from Zero to Confident

TypeScript's type system provides significant advantages for building robust and maintainable applications. A cornerstone of this system, often initially…

TypeScript's type system provides significant advantages for building robust and maintainable applications. A cornerstone of this system, often initially perceived as complex, is generics. Generics enable you to write components that work with a variety of data types, rather than a single one, providing flexibility without sacrificing type safety. This article will guide you from the fundamental concepts of generics through advanced patterns like constraints, conditional types, and utility types, equipping you to leverage them confidently in your projects.

Understanding the Core Concept: Type Variables

At its heart, a generic is a type variable, typically denoted by T (for Type), K (for Key), V (for Value), or similar single uppercase letters, that acts as a placeholder for a concrete type. This type variable is specified when the component (function, class, interface, or type alias) is used, allowing it to operate on various types while maintaining strict type checking.

Generic Functions

The simplest way to grasp generics is through functions. Consider a function that returns the first element of an array. Without generics, you might have to create overloaded functions or use any, losing type information:

// Bad: Loses type information, 'element' will be 'any'
function firstAny(arr: any[]): any {
  return arr[0];
}

// Bad: Only works for numbers
function firstNumber(arr: number[]): number | undefined {
  return arr[0];
}

// Good: Generic function
function first<T>(arr: T[]): T | undefined {
  if (arr.length === 0) {
    return undefined;
  }
  return arr[0];
}

const numbers = first([1, 2, 3]); // Type: number | undefined
const strings = first(["a", "b"]); // Type: string | undefined
const booleans = first([true, false]); // Type: boolean | undefined
const empty = first([]); // Type: unknown | undefined (inferred from empty array literal)
const inferredFromContext = first<string>([]); // Type: string | undefined (explicit type argument)

console.log(numbers);
console.log(strings);
console.log(booleans);

In function first<T>(arr: T[]): T | undefined, T is the type variable. When you call first([1, 2, 3]), TypeScript infers that T should be number. When you call first(["a", "b"]), T becomes string. The function's return type then correctly reflects the type of the elements in the input array.

Generic Interfaces and Type Aliases

Generics can also be applied to interfaces and type aliases, allowing them to describe structures that hold or operate on types specified by the consumer.

// Generic Interface
interface Box<T> {
  value: T;
}

const numberBox: Box<number> = { value: 123 };
const stringBox: Box<string> = { value: "hello" };
// const booleanBox: Box<boolean> = { value: "world" }; // Error: Type '"world"' is not assignable to type 'boolean'.

// Generic Type Alias
type Result<Data, ErrorType = string> = {
  success: true;
  data: Data;
} | {
  success: false;
  error: ErrorType;
};

const successResponse: Result<{ id: string; name: string }> = {
  success: true,
  data: { id: "user-123", name: "Alice" },
};

const errorResponse: Result<null, number> = {
  success: false,
  error: 404,
};

// ErrorType defaults to string
const defaultErrorResponse: Result<null> = {
  success: false,
  error: "Something went wrong",
};

Notice ErrorType = string in the Result type alias. This demonstrates a default generic type, where if ErrorType is not explicitly provided, it defaults to string.

Generic Classes

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

class DataStore<T> {
  private data: T[] = [];

  add(item: T): void {
    this.data.push(item);
  }

  get(index: number): T | undefined {
    return this.data[index];
  }

  getAll(): T[] {
    return [...this.data]; // Return a copy
  }
}

const numberStore = new DataStore<number>();
numberStore.add(10);
numberStore.add(20);
// numberStore.add("hello"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
console.log(numberStore.get(0)); // Type: number | undefined

const stringStore = new DataStore<string>();
stringStore.add("foo");
stringStore.add("bar");
console.log(stringStore.getAll()); // Type: string[]

Constraints: Guiding the Type Variable

Sometimes you need to ensure that a generic type parameter has certain properties or capabilities. This is where generic constraints come in. You can restrict the types that T can be by using the extends keyword.

Ensuring Property Existence

A common scenario is wanting to access a property on a generic object. Without a constraint, TypeScript wouldn't know if the property exists:

// Problematic: Property 'name' does not exist on type 'T'.
// function printName<T>(obj: T): void {
//   console.log(obj.name);
// }

// Solution: Constrain T to have a 'name' property
interface HasName {
  name: string;
}

function printName<T extends HasName>(obj: T): void {
  console.log(obj.name);
}

printName({ name: "Alice", age: 30 }); // Works
// printName({ age: 25 }); // Error: Argument of type '{ age: number; }' is not assignable to parameter of type 'HasName'.
printName({ name: "Bob" }); // Works

The extends HasName constraint tells TypeScript that any type used for T must at least contain a name: string property.

Keyof Type Operator

Another powerful constraint involves ensuring that a generic key corresponds to a key of a generic object. This pattern is crucial for creating type-safe object accessors.

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

const user = { name: "Carol", age: 40, city: "New York" };

const userName = getProperty(user, "name"); // Type: string
const userAge = getProperty(user, "age");   // Type: number
// const userAddress = getProperty(user, "address"); // Error: Argument of type '"address"' is not assignable to parameter of type '"name" | "age" | "city"'.

console.log(`User name: ${userName}, User age: ${userAge}`);

Here, K extends keyof T ensures that the key parameter's type is a literal string or union of strings that are valid keys of the obj parameter's type T. The return type T[K] then correctly models the type of the value at that specific key.

Conditional Types: Logic in the Type System

Conditional types allow you to define types that act like conditional expressions (if/else statements) in your type system. They take the form T extends U ? X : Y, meaning "if type T is assignable to type U, then the type is X; otherwise, it's Y."

type IsString<T> = T extends string ? "Yes, it's a string" : "No, not a string";

type A = IsString<"hello">; // Type: "Yes, it's a string"
type B = IsString<123>;     // Type: "No, not a string"
type C = IsString<boolean>; // Type: "No, not a string"

// More complex example: Extracting a type from an array
type ElementType<T> = T extends (infer U)[] ? U : T;

type ArrayElements = ElementType<string[]>; // Type: string
type NonArray = ElementType<number>;        // Type: number
type TupleElements = ElementType<[string, number]>; // Type: string | number

// Infer keyword
type FunctionReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

type MyFunction = () => { id: string, name: string };
type ReturnTypeOfMyFunction = FunctionReturnType<MyFunction>; // Type: { id: string, name: string }

type NonFunction = number;
type ReturnTypeOfNonFunction = FunctionReturnType<NonFunction>; // Type: any

The infer keyword is particularly powerful within conditional types. It allows you to "capture" a type that's part of a type inference, like the element type of an array or the return type of a function, and use it in the true branch of the conditional type.

Advanced Patterns: Utility Types and Mapped Types

TypeScript provides a rich set of built-in Utility Types that are, themselves, implemented using generics and conditional types. Understanding how they work internally reinforces generic concepts.

  • Partial<T>: Makes all properties of T optional.
  • Readonly<T>: Makes all properties of T readonly.
  • Pick<T, K>: Constructs a type by picking the set of properties K from T.
  • Omit<T, K>: Constructs a type by picking all properties from T and then removing K.
  • Exclude<UnionType, ExcludedMembers>: Excludes from UnionType all union members that are assignable to ExcludedMembers.
  • Extract<Type, Union>: Extracts from Type all union members that are assignable to Union.
interface User {
  id: string;
  name: string;
  email: string;
  age?: number;
}

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

const userUpdate: PartialUser = { name: "Dave", age: 45 };

// Pick<T, K>
type UserSummary = Pick<User, "id" | "name">;
// Equivalent to: { id: string; name: string; }

const summary: UserSummary = { id: "abc-123", name: "Eve" };

// Omit<T, K>
type UserCredentials = Omit<User, "name" | "age">;
// Equivalent to: { id: string; email: string; }

const credentials: UserCredentials = { id: "xyz-789", email: "eve@example.com" };

// Readonly<T>
type ImmutableUser = Readonly<User>;
const immutableUser: ImmutableUser = { id: "1", name: "Frank", email: "frank@test.com" };
// immutableUser.name = "George"; // Error: Cannot assign to 'name' because it is a read-only property.

// Exclude<UnionType, ExcludedMembers>
type MixedTypes = string | number | boolean | null;
type NonNullAndNonBoolean = Exclude<MixedTypes, null | boolean>; // string | number

// Extract<Type, Union>
type StringAndNumber = Extract<MixedTypes, string | number>; // string | number

Many of these utility types internally use Mapped Types, which allow you to create new types by transforming the properties of an existing type. They iterate over keys of an interface/type and apply a transformation. For example, Partial can be implemented as:

type MyPartial<T> = {
  [P in keyof T]?: T[P]; // For each property P in T, make it optional
};

Common Pitfalls and Troubleshooting

  • Forgetting to specify type arguments: If TypeScript can't infer the type, it might default to any or unknown, losing type safety. Explicitly providing type arguments (e.g., first<string>([])) can resolve this.
  • Incorrect constraints: If your generic function gives an error like "Property 'X' does not exist on type 'T'", it usually means T isn't constrained properly to guarantee X's presence. Add an extends clause.
  • Overly broad constraints: Using extends object when you need specific properties. Be as precise as possible with your constraints to maximize type safety.
  • Understanding infer scope: The type variable introduced by infer is only available in the true branch of the conditional type where it's declared.
  • Debugging complex conditional types: Use your IDE's "hover over type" feature to see the resolved type at various stages. This is invaluable for understanding how conditional types simplify.
  • Runtime vs. Compile-time: Remember that generics are purely a compile-time construct in TypeScript. They provide type safety during development but have no runtime presence or impact on JavaScript output.

Back to the knowledge base · Ask the AI assistant