Background pattern
New

A Simple Guide to TypeScript Generics

Generics are a powerful feature in TypeScript that allows you to write reusable, flexible, and type-safe code by creating components and functions that work with a variety of types while maintaining type safety.

typescriptgenericstypesprogrammingjavascript

Generics are a powerful feature in TypeScript that allows you to write reusable, flexible, and type-safe code by creating components and functions that work with a variety of types while maintaining type safety. Instead of writing multiple functions that do the same thing but with different types, you can write a single generic function that adapts to the type it receives.

NOTE: This post assumes you have a basic understanding of TypeScript and JavaScript. Generics can be complex at first, but once you understand the concept, they become an invaluable tool in your TypeScript toolkit.

## The Problem Without Generics

Before diving into generics, let's look at why we need them. Consider a function that returns whatever is passed to it:

without-generics.ts
function identity(arg: string): string {
  return arg;
}

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

function identityBoolean(arg: boolean): boolean {
  return arg;
}

This is repetitive and not scalable. We could use any, but we lose type safety:

using-any.ts
function identity(arg: any): any {
  return arg;
}

const output = identity('hello');
// We don't know what type output is, which defeats TypeScript's purpose

## Basic Generic Syntax

With generics, we can create a single function that works with any type while preserving type information:

basic-generic.ts
function identity<T>(arg: T): T {
  return arg;
}

// TypeScript can infer the type
const stringResult = identity('hello'); // type is string
const numberResult = identity(42); // type is number
const booleanResult = identity(true); // type is boolean

// You can also explicitly specify the type
const explicitResult = identity<string>('hello'); // type is string

The <T> is a type variable - a placeholder for a type that will be specified when the function is called. You can think of it like a function argument, but for types instead of values.

## Generic Interfaces

You can also use generics with interfaces:

generic-interface.ts
interface Box<T> {
  contents: T;
}

const stringBox: Box<string> = {
  contents: 'Hello, Generics!'
};

const numberBox: Box<number> = {
  contents: 42
};

// You can even nest generics
const boxBox: Box<Box<string>> = {
  contents: {
    contents: 'Nested box!'
  }
};

## Generic Classes and Functional Alternatives

While classes can be generic, modern TypeScript/JavaScript often prefers functional patterns:

### Class-based approach (traditional):

generic-class.ts
class Storage<T> {
  private items: T[] = [];

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

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

  getAll(): T[] {
    return [...this.items];
  }
}

// Usage
const stringStorage = new Storage<string>();
stringStorage.add('Item 1');
stringStorage.add('Item 2');

const numberStorage = new Storage<number>();
numberStorage.add(1);
numberStorage.add(2);

### Functional approach (modern):

generic-functional.ts
// Factory function with generics
function createStorage<T>() {
  let items: T[] = [];

  return {
    add(item: T): void {
      items.push(item);
    },

    get(index: number): T | undefined {
      return items[index];
    },

    getAll(): T[] {
      return [...items];
    }
  };
}

// Usage - same as class but with functional pattern
const stringStorage = createStorage<string>();
stringStorage.add('Item 1');
stringStorage.add('Item 2');

const numberStorage = createStorage<number>();
numberStorage.add(1);
numberStorage.add(2);

## Generic Constraints

Sometimes you want to constrain a generic to only work with certain types. You can do this using the extends keyword:

generic-constraint.ts
interface Lengthwise {
  length: number;
}

function logLength<T extends Lengthwise>(arg: T): void {
  console.log(arg.length);
}

logLength('hello'); // Works - strings have a length property
logLength([1, 2, 3]); // Works - arrays have a length property
logLength({ length: 10, value: 'test' }); // Works - has length property
// logLength(42); // Error - numbers don't have a length property

You can also constrain to specific object shapes:

constraint-properties.ts
interface WithId {
  id: number;
}

function findById<T extends WithId>(items: T[], id: number): T | undefined {
  return items.find((item) => item.id === id);
}

const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' }
];

const user = findById(users, 1); // type is { id: number; name: string; } | undefined

## Using Type Parameters in Generic Constraints

You can reference one type parameter in the constraint of another:

type-parameter-constraint.ts
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = {
  name: 'Alice',
  age: 30,
  email: 'alice@example.com'
};

const name = getProperty(user, 'name'); // type is string
const age = getProperty(user, 'age'); // type is number
// const invalid = getProperty(user, "invalid"); // Error - "invalid" is not a key of user

This is extremely powerful for creating type-safe property access.

## Generic Utility Types

TypeScript provides several built-in generic utility types:

### Partial<T> - Makes all properties optional

partial.ts
interface User {
  id: number;
  name: string;
  email: string;
}

function updateUser(id: number, updates: Partial<User>): void {
  // updates can have any subset of User properties
  const user: User = { id, name: '', email: '' };
  Object.assign(user, updates);
}

updateUser(1, { name: 'New Name' }); // Works

### Required<T> - Makes all properties required

required.ts
interface PartialUser {
  id?: number;
  name?: string;
  email?: string;
}

type FullUser = Required<PartialUser>;
// All properties are now required

### Pick<T, K> - Select specific properties

pick.ts
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

type PublicUser = Pick<User, 'id' | 'name' | 'email'>;
// PublicUser has only id, name, and email

### Omit<T, K> - Remove specific properties

omit.ts
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}

type PublicUser = Omit<User, 'password'>;
// PublicUser has everything except password

## Real-World Example: API Response Handler

Here's a practical example of using generics for API handling:

api-response.ts
interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

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

interface Post {
  id: number;
  title: string;
  content: string;
}

async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  const response = await fetch(url);
  const data = await response.json();

  return {
    data,
    status: response.status,
    message: response.statusText
  };
}

// Usage
async function getUser(id: number) {
  const response = await fetchApi<User>(`/api/users/${id}`);
  // response.data is typed as User
  console.log(response.data.name);
}

async function getPost(id: number) {
  const response = await fetchApi<Post>(`/api/posts/${id}`);
  // response.data is typed as Post
  console.log(response.data.title);
}

## Real-World Example: Generic Repository Pattern

Using a functional approach with TypeScript generics:

repository.ts
interface Entity {
  id: number;
}

// Functional repository definition using TypeScript generics
interface Repository<T extends Entity> {
  findById(id: number): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(entity: Omit<T, 'id'>): Promise<T>;
  update(id: number, updates: Partial<T>): Promise<T | null>;
  delete(id: number): Promise<boolean>;
}

// Factory function to create a repository
function createRepository<T extends Entity>(initialData: T[] = []): Repository<T> {
  let items: T[] = [...initialData];

  return {
    async findById(id: number): Promise<T | null> {
      return items.find((item) => item.id === id) || null;
    },

    async findAll(): Promise<T[]> {
      return [...items];
    },

    async create(entity: Omit<T, 'id'>): Promise<T> {
      const newEntity: T = {
        id: items.length + 1,
        ...entity
      } as T;
      items.push(newEntity);
      return newEntity;
    },

    async update(id: number, updates: Partial<T>): Promise<T | null> {
      const index = items.findIndex((item) => item.id === id);
      if (index === -1) return null;

      items[index] = { ...items[index], ...updates };
      return items[index];
    },

    async delete(id: number): Promise<boolean> {
      const index = items.findIndex((item) => item.id === id);
      if (index === -1) return false;

      items.splice(index, 1);
      return true;
    }
  };
}

// Usage example
interface User extends Entity {
  name: string;
  email: string;
}

// Create a user repository using the factory function
const userRepository = createRepository<User>([{ id: 1, name: 'Alice', email: 'alice@example.com' }]);

// Use the repository methods
async function example() {
  const user = await userRepository.findById(1);
  const allUsers = await userRepository.findAll();
  const newUser = await userRepository.create({ name: 'Bob', email: 'bob@example.com' });
  const updated = await userRepository.update(1, { name: 'Alice Updated' });
  const deleted = await userRepository.delete(1);
}

## Best Practices

  1. Prefer functional patterns: Modern TypeScript/JavaScript favors functional programming patterns over classes. Use factory functions and composition over class inheritance where possible.

  2. Use descriptive type parameter names: While T is conventional, use more descriptive names when appropriate:

    <T, U, V> // For multiple type parameters
    <TKey, TValue> // For key-value pairs
    <TComponent> // For React components
  3. Use constraints when needed: Don't make your generic too permissive. Use extends to enforce requirements.

  4. Consider defaults: You can provide default types for generics:

    interface Box<T = string> {
      contents: T;
    }
    
    const defaultBox: Box = { contents: 'hello' }; // T is string
  5. Don't overuse: Not everything needs to be generic. Use generics when you genuinely need type flexibility.

## Conclusion

Generics are a fundamental tool in TypeScript that allows you to write reusable, type-safe code. They enable you to create functions, interfaces, and classes that work with a variety of types while maintaining full type safety. By mastering generics, you'll write more maintainable and flexible code that catches errors at compile time rather than runtime.

Published on January 5, 2026

8 min read

Found an Issue!

Find an issue with this post? Think you could clarify, update or add something? All my posts are available to edit on Github. Any fix, little or small, is appreciated!

Edit on GitHub

Last updated on