DEV Community

hamid zangiabadi
hamid zangiabadi

Posted on

TypeScript Generics: The Magic Wand for Reusable Type-Safe Code

Ever felt like you're writing the same function over and over, just with different types? Welcome to the world of TypeScript generics – where code reusability meets type safety in perfect harmony.

What Are Generics?

Imagine you're building a toolbox. Instead of having separate hammers for nails, screws, and pins, wouldn't it be amazing to have one universal hammer that adapts to whatever you're working with? That's exactly what generics do for your code.

Generics allow you to create reusable components that work with multiple types while maintaining type safety. Think of them as type variables – placeholders that get filled in when you actually use the function, class, or interface.

The Problem Generics Solve

Let's start. Say you want to create a function that returns the first item from an array:

function getFirstString(items: string[]): string {
  return items[0];
}

function getFirstNumber(items: number[]): number {
  return items[0];
}

function getFirstBoolean(items: boolean[]): boolean {
  return items[0];
}

Enter fullscreen mode Exit fullscreen mode

This approach is like having a separate key for every door in your house. It works, but it's unnecessarily complicated.

Enter Generics: The Master Key 🗝️

Here's how generics solve this elegantly:

function getFirst<T>(items: T[]): T {
  return items[0];
}

// Now we can use it with any type!
const firstString = getFirst(['apple', 'banana', 'cherry']); // Type: string
const firstNumber = getFirst([1, 2, 3]); // Type: number
const firstUser = getFirst([{name: 'Alice'}, {name: 'Bob'}]); // Type: {name: string}
Enter fullscreen mode Exit fullscreen mode

The <T> is our type parameter – a placeholder that TypeScript fills in based on how we use the function. It's like saying "Hey TypeScript, I'll tell you what type this is when I actually use it."


Generic Syntax: Breaking It Down

The basic syntax follows this pattern:

function functionName<TypeParameter>(param: TypeParameter): TypeParameter {
  // function body
}
Enter fullscreen mode Exit fullscreen mode

You can name your type parameters anything, but common conventions include:

  • T for "Type" (most common)
  • K for "Key" and V for "Value" (in key-value scenarios)
  • U, V, W for additional type parameters

Real-World Examples

1. API Response Handler

interface ApiResponse<T> {
  data: T;
  status: number;
  message: string;
}

// Now we can handle different types of API responses
const userResponse: ApiResponse<User> = {
  data: { id: 1, name: 'Alice' },
  status: 200,
  message: 'Success'
};

const productsResponse: ApiResponse<Product[]> = {
  data: [{ id: 1, name: 'Laptop' }, { id: 2, name: 'Phone' }],
  status: 200,
  message: 'Success'
};
Enter fullscreen mode Exit fullscreen mode

2. Generic Promise Wrapper

async function fetchData<T>(url: string): Promise<T> {
  const response = await fetch(url);
  return response.json();
}

// TypeScript knows exactly what type we're getting back
const user = await fetchData<User>('/api/users/1');
const posts = await fetchData<Post[]>('/api/posts');
Enter fullscreen mode Exit fullscreen mode

3. Type-Safe Storage System

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

// Different storage instances for different types
const userStorage = new Storage<User>();
const productStorage = new Storage<Product>();

userStorage.add({ id: 1, name: 'Alice' }); // ✅ Works
userStorage.add('invalid'); // ❌ TypeScript error!
Enter fullscreen mode Exit fullscreen mode

Advanced Generic Patterns

Generic Constraints: Setting Boundaries

Sometimes you want your generic to work with any type, but that type must have certain properties:

interface Identifiable {
  id: number;
}

function updateItem<T extends Identifiable>(item: T, updates: Partial<T>): T {
  return { ...item, ...updates };
}

// This works because User has an id property
interface User extends Identifiable {
  name: string;
  email: string;
}

const user: User = { id: 1, name: 'Alice', email: 'alice@example.com' };
const updatedUser = updateItem(user, { name: 'Alice Smith' });
Enter fullscreen mode Exit fullscreen mode

Multiple Type Parameters

function merge<T, U>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const personalInfo = { name: 'Alice', age: 30 };
const workInfo = { company: 'TechCorp', position: 'Developer' };

const fullProfile = merge(personalInfo, workInfo);
// Type: { name: string; age: number; company: string; position: string; }
Enter fullscreen mode Exit fullscreen mode

Conditional Types (The Mind-Bender)

type ApiResult<T> = T extends string 
  ? { message: T } 
  : T extends number 
  ? { count: T }
  : { data: T };

// TypeScript automatically infers the right shape
const stringResult: ApiResult<string> = { message: "Hello" };
const numberResult: ApiResult<number> = { count: 42 };
const objectResult: ApiResult<User> = { data: { id: 1, name: "Alice" } };
Enter fullscreen mode Exit fullscreen mode

Utility Types: Generics in Action

TypeScript comes with powerful built-in generic utility types:

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

// Pick only specific properties
type PublicUser = Pick<User, 'id' | 'name'>;

// Make all properties optional
type PartialUser = Partial<User>;

// Exclude specific properties
type UserWithoutPassword = Omit<User, 'password'>;

// Create a record type
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

1. The Generic Type Erasure Trap

// ❌ This won't work as expected
function createArray<T>(length: number, value: T): T[] {
  return new Array(length).fill(value);
}

// ✅ Better approach
function createArray<T>(length: number, factory: () => T): T[] {
  return Array.from({ length }, factory);
}
Enter fullscreen mode Exit fullscreen mode

2. Forgetting About Type Inference

// ❌ Unnecessary explicit type
const numbers = identity<number[]>([1, 2, 3]);

// ✅ Let TypeScript infer
const numbers = identity([1, 2, 3]); // Type is automatically number[]
Enter fullscreen mode Exit fullscreen mode

When NOT to Use Generics

Generics are powerful, but they're not always the answer:

  • Simple, one-off functions that will never need to handle multiple types
  • When the type is always the same – just use the specific type
  • Over-abstraction – if your generic has more than 3-4 type parameters, reconsider your design

Wrapping Up: Your Generic Journey Starts Here

Generics might seem intimidating at first, but they're one of TypeScript's most powerful features. They let you write flexible, reusable code without sacrificing type safety – like having your cake and eating it too! 🍰

Start small: convert a few of your existing functions to use generics. You'll quickly see how they can eliminate code duplication and make your APIs more flexible. Before you know it, you'll be thinking in generics and wondering how you ever lived without them.

Remember, the best way to learn generics is by using them. So fire up your editor, pick a function that handles multiple types, and give it the generic treatment. Your future self (and your teammates) will thank you!

Top comments (0)