Programming

How to Write Reusable Code with TypeScript Generics

Tech Setup5 min read
TS

Tech Setup

Published September 28, 2026 · Editorial policy

How to Write Reusable Code with TypeScript Generics

You are building a client-side caching layer for a Next.js application, and you need a generic fetchJSON helper that accepts any API response type without falling back to any. Every time you try to type it, you either end up with repetitive type assertions, functions that lose context when passed down to custom hooks, or complex conditional types that trigger cryptic red squiggly lines in your IDE. Generics solve this, but the syntax often feels like alphabet soup until you map it directly to everyday JavaScript patterns.

The TL;DR Fix: Type Parameters in 30 Seconds

If you just want your function to accept a type and return it safely, stop using any and add a type parameter <T> right before your function arguments.

// api.ts
export async function fetchJSON<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }
  return response.json() as T;
}

Call it by passing the expected shape directly into the function call:

// userComponent.ts
interface User {
  id: string;
  name: string;
  email: string;
}

// TypeScript now knows 'user' is shaped like the User interface
const user = await fetchJSON<User>('/api/user/1');
console.log(user.name); 

This bypasses unsafe type assertions (as User) at the call site and centralizes your response parsing.


When to Stop Using any and Reach for Generics

Type safety breaks down the moment you write utility functions that process unknown data structures. Consider a simple local storage wrapper. You want a function that retrieves an item and parses it from JSON.

If you type the return value as any, you lose autocompletion and compiler protection across your entire codebase. If you type it as unknown, you force yourself to write type guards everywhere. Generics let you defer the decision of what type the function handles until the exact moment you call it, bridging the gap between dynamic JavaScript and strict TypeScript.

Building a Strongly-Typed Local Storage Wrapper

Create a utility file named storage.ts in your src/utils directory to manage browser storage safely.

// src/utils/storage.ts
export function getStoredItem<T>(key: string): T | null {
  const item = localStorage.getItem(key);
  if (!item) return null;
  
  try {
    return JSON.parse(item) as T;
  } catch (error) {
    console.error(`Failed to parse localStorage key "${key}":`, error);
    return null;
  }
}

export function setStoredItem<T>(key: string, value: T): void {
  localStorage.setItem(key, JSON.stringify(value));
}

Now, initialize and consume this storage helper in a feature component:

// src/features/settings.ts
import { getStoredItem, setStoredItem } from '../utils/storage';

interface UserPreferences {
  theme: 'dark' | 'light';
  notificationsEnabled: boolean;
}

// Saving infers the type automatically from the second argument
setStoredItem('preferences', { theme: 'dark', notificationsEnabled: true });

// Retrieving requires passing the generic argument <UserPreferences>
const prefs = getStoredItem<UserPreferences>('preferences');

if (prefs) {
  // TypeScript knows prefs.theme is strictly 'dark' | 'light'
  document.body.className = prefs.theme;
}

Constraining Generics: When <T> Needs Boundaries

Sometimes an unconstrained generic <T> is too permissive. If you write a function that formats an entity's display name, you might find yourself accepting strings, numbers, or objects that don't actually possess a name property.

TypeScript lets you enforce rules on your generics using the extends keyword. This tells the compiler: "Accept any type, as long as it contains these specific foundational properties."

Writing a Paginated List Mapper

Imagine building a generic table component that takes an array of items, maps over them, and renders rows. You want to guarantee that every item passed in has a unique id property, regardless of whether it's a User, a Product, or an Order.

// src/components/Table.ts
interface Identifiable {
  id: string | number;
}

export function extractIds<T extends Identifiable>(items: T[]): Array<T['id']> {
  return items.map(item => item.id);
}

// Valid usage: User and Product both have an 'id' field
const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
const userIds = extractIds(users); // Type is number[]

// Invalid usage: This will fail compilation immediately
// const invalid = extractIds(['alice', 'bob']); 
// Error: Type 'string' is not assignable to type 'Identifiable'.

By constraining <T extends Identifiable>, you prevent runtime errors by catching invalid data structures during compilation.


Multiple Type Parameters and Default Types

Real-world applications rarely deal with single data types in isolation. Often, you deal with transformations, dictionaries, or API responses wrapped in metadata (like pagination or error objects).

You can pass multiple type parameters to a function or interface by separating them with commas, much like function arguments: <T, U, V>.

Creating a Standardized API Response Wrapper

Design a wrapper type that normalizes all backend responses across your application.

// src/types/api.ts
export interface ApiResponse<T, E = string> {
  status: number;
  success: boolean;
  data: T | null;
  error: E | null;
}

export async function handleApiResponse<T>(
  responsePromise: Promise<Response>
): Promise<ApiResponse<T>> {
  try {
    const res = await responsePromise;
    const json = await res.json();

    if (!res.ok) {
      return {
        status: res.status,
        success: false,
        data: null,
        error: json.message || 'An unknown error occurred',
      };
    }

    return {
      status: res.status,
      success: true,
      data: json as T,
      error: null,
    };
  } catch (err) {
    return {
      status: 500,
      success: false,
      data: null,
      error: err instanceof Error ? err.message : 'Network error',
    };
  }
}

Notice the E = string syntax in the ApiResponse<T, E> interface. This is a default type parameter. If you only pass one type (ApiResponse<User>), TypeScript automatically falls back to using string for the error property. If your backend uses a custom error object, you can override it: ApiResponse<User, DetailedErrorObject>.


Generics in React Hooks: Custom State Handlers

If you write React components, generics are essential for building robust custom hooks. Without them, hooks like useLocalStorage or useAsync force you to cast types at every consumption point.

Building a Typed useLocalStorage Hook

Ensure you have React installed (npm install react@^18.0.0 @types/react@^18.0.0). Create a hook that syncs state directly with browser storage while keeping strict type inference.

// src/hooks/useLocalStorage.ts
import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  }, [key, storedValue]);

  return [storedValue, setStoredValue];
}

Consume the hook inside a React functional component with full type safety and state updater support:

// src/components/SettingsToggle.tsx
import React from 'react';
import { useLocalStorage } from '../hooks/useLocalStorage';

export function SettingsToggle() {
  // TypeScript infers 'isDarkMode' is boolean based on the initial value 'false'
  const [isDarkMode, setIsDarkMode] = useLocalStorage<boolean>('dark-mode', false);

  return (
    <button onClick={() => setIsDarkMode(prev => !prev)}>
      Current mode: {isDarkMode ? 'Dark' : 'Light'}
    </button>
  );
}

If This Doesn't Work: Common TypeScript Generic Errors

When writing complex generic code, TypeScript error messages can become verbose and obscure. Here are the three most common failures developers face and how to fix them.

1. "Property 'X' does not exist on type 'T'"

  • The Cause: You attempted to access a property on a generic variable <T> without constraining it first.
  • The Fix: Add an interface constraint using extends so the compiler knows the property exists.
// Broken
function getLength<T>(item: T): number {
  return item.length; // Error: Property 'length' does not exist on type 'T'
}

// Fixed
interface HasLength {
  length: number;
}
function getLength<T extends HasLength>(item: T): number {
  return item.length; // Works perfectly
}

2. TypeScript infers unknown or {}, ignoring your runtime types

  • The Cause: You wrote a generic function, but didn't pass an explicit type argument, and the arguments you passed into the function didn't contain enough structural information for TypeScript to infer it.
  • The Fix: Explicitly supply the type parameter at the call site (fetchJSON<User>('/api')), or provide a default type fallback in your function signature (<T = DefaultModel>).

3. Arrow function syntax parsing errors in .tsx files

  • The Cause: When writing generic arrow functions in JSX/TSX files, the opening <T> bracket conflicts with JSX tag parsing.
  • The Fix: Add a trailing comma after the type parameter to disambiguate it from JSX:
// Broken in .tsx files
const identidad = <T>(arg: T): T => arg; // Parser thinks this is an unclosed JSX tag

// Fixed
const identidad = <T,>(arg: T): T => arg;

Prevention and Best Practices

Generics are powerful, but overusing them leads to unmaintainable codebases. Follow these guardrails to keep your types clean:

  1. Don't use a generic if you only use it once. If a function accepts a single type and returns it without any transformations or relationships to other arguments, you don't need a generic parameter. Use concrete types or union types instead.
  2. Name your parameters descriptively. While single-letter conventions like <T> (Type), <K> (Key), and <V> (Value) are standard for simple utilities, use descriptive names like <TUser> or <TResponse> when writing complex multi-parameter components or hooks.
  3. Prefer inference over explicit arguments. Let TypeScript infer types from function parameters whenever possible. Only specify explicit generic arguments at the call site when initializing state, calling API layers, or overriding loose defaults.