Skip to main content
MSH Logo

Understanding TypeScript Generics

Published on
1// Generic function example
2function identity<T>(value: T): T {
3  return value;
4}
5
6// Usage with different types
7const stringValue = identity<string>('hello'); // string
8const numberValue = identity<number>(42); // number
9const inferred = identity('world'); // TypeScript infers string or whatever type you pass in

This example demonstrates the core concept of generics: creating reusable code that works with multiple types while maintaining type safety. The <T> syntax defines a type parameter that gets replaced with the actual type when the function is called.

What are Generics?

Generics are TypeScript's way of creating reusable components that work with multiple types rather than a single type.

Think of them as type variables; placeholders that get filled in with specific types.

The Problem Generics Solve:

Without generics, you'd need to create separate functions for each type:

1// Without generics - repetitive code
2function identityString(value: string): string {
3  return value;
4}
5
6function identityNumber(value: number): number {
7  return value;
8}
9
10// With generics - one function for all types
11function identity<T>(value: T): T {
12  return value;
13}

Key Benefits:

  • Type Safety: Maintain type checking while working with different types
  • Code Reusability: Write once, use with multiple types
  • Better IntelliSense: Get accurate autocomplete and type hints
  • No Runtime Overhead: Generics are purely compile-time constructs

Basic Generic Syntax

Generic Functions

The most common use of generics is in functions:

1function getFirst<T>(items: T[]): T | undefined {
2  return items[0];
3}
4
5// Usage
6const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
7const firstString = getFirst<string>(['a', 'b', 'c']); // string | undefined
8
9// TypeScript can infer the type
10const inferred = getFirst([true, false]); // boolean | undefined

Type Inference: TypeScript can often infer the generic type from the arguments, so you don't always need to explicitly specify it.

Generic Interfaces

Interfaces can also be generic:

1interface Box<T> {
2  value: T;
3  getValue(): T;
4  setValue(value: T): void;
5}
6
7// Usage
8const numberBox: Box<number> = {
9  value: 42,
10  getValue() {
11      return this.value;
12  },
13  setValue(value: number) {
14      this.value = value;
15  },
16};

Generic Classes

Classes can use generics too:

1class Stack<T> {
2  private items: T[] = [];
3
4  push(item: T): void {
5      this.items.push(item);
6  }
7
8  pop(): T | undefined {
9      return this.items.pop();
10  }
11}
12
13// Usage
14const numberStack = new Stack<number>();
15numberStack.push(1);
16numberStack.push(2);
17const top = numberStack.pop(); // number | undefined

Generic Constraints

Constraints allow you to limit what types can be used with a generic:

1// Constraint: T must have a length property
2function getLength<T extends { length: number }>(item: T): number {
3  return item.length;
4}
5
6// Works with arrays, strings, etc.
7getLength([1, 2, 3]); // 3
8getLength('hello'); // 5
9// getLength(42); // Error: number doesn't have length

Using keyof with Constraints

A powerful pattern is constraining to object keys:

1function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
2  return obj[key];
3}
4
5interface User {
6  id: string;
7  name: string;
8  email: string;
9}
10
11const user: User = {
12  id: '1',
13  name: 'John',
14  email: 'john@example.com',
15};
16
17const name = getProperty(user, 'name'); // string
18// const invalid = getProperty(user, 'age'); // Error: 'age' doesn't exist

Why This Works: K extends keyof T ensures that key must be a valid property name of T. TypeScript then knows the return type is T[K], which is the type of that specific property.

Common Use Cases

Array Utilities

1// Get last element
2function getLast<T>(items: T[]): T | undefined {
3  return items[items.length - 1];
4}
5
6// Filter by property
7function filterBy<T, K extends keyof T>(
8  items: T[],
9  key: K,
10  value: T[K]
11): T[] {
12  return items.filter((item) => item[key] === value);
13}
14
15// Usage
16interface Person {
17  name: string;
18  age: number;
19}
20
21const people: Person[] = [
22  { name: 'Alice', age: 30 },
23  { name: 'Bob', age: 25 },
24];
25
26const adults = filterBy(people, 'age', 30); // Person[]

API Response Wrapper

1interface ApiResponse<T> {
2  data: T;
3  status: number;
4  message?: string;
5}
6
7async function fetchData<T>(url: string): Promise<ApiResponse<T>> {
8  const response = await fetch(url);
9  const data = await response.json();
10  return {
11      data,
12      status: response.status,
13  };
14}
15
16// Usage
17interface User {
18  id: string;
19  name: string;
20}
21
22const userResponse = await fetchData<User>('/api/user');
23// userResponse.data is typed as User

Why This Pattern Works: This ensures that when you fetch different endpoints, the data property is correctly typed. Fetching /api/user gives you ApiResponse<User>, while /api/products could give you ApiResponse<Product[]>.

Best Practices

  1. Leverage Type Inference
1// TypeScript can infer the type
2const result = identity('hello'); // string
3
4// Only specify when necessary
5const result2 = identity<string | null>(null); // string | null
  1. Use Constraints Judiciously
1// Good: Constraint enables functionality
2function getLength<T extends { length: number }>(item: T): number {
3  return item.length;
4}
5
6// Avoid: Unnecessary constraint limits flexibility
7function process<T extends string>(value: T): T {
8  return value;
9}
10// Better: Let it work with any type
11function process<T>(value: T): T {
12  return value;
13}
  1. Combine Generics with Utility Types
1function update<T>(obj: T, updates: Partial<T>): T {
2  return { ...obj, ...updates };
3}
4
5function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
6  const result = {} as Pick<T, K>;
7  keys.forEach((key) => {
8      result[key] = obj[key];
9  });
10  return result;
11}

Conclusion

Generics are a fundamental feature of TypeScript that enable you to write reusable, type-safe code. They allow you to create components that work with multiple types while maintaining full type safety and IntelliSense support.

Key Takeaways:

  • Generics create reusable code that works with multiple types
  • Type inference often eliminates the need to explicitly specify types
  • Constraints allow you to limit and enable specific functionality
  • Generics work with functions, interfaces, classes, and type aliases

Remember: Generics are about creating flexible, reusable code while maintaining type safety. Start simple and add complexity only when needed.

References

GET IN TOUCH

Let's work together

I build fast, accessible, and delightful digital experiences for the web.
Whether you have a project in mind or just want to connect, I'd love to hear from you.

or reach out directly at hello@mohammadshehadeh.com