Learning TypeScript Generics Through Real Examples

Introduction: Why Generics?

Let’s start with a question I get asked all the time:

“I’ve been writing TypeScript for a year, but I still don’t really understand generics. Should I just copy-paste them from Stack Overflow?”

Short answer: No. Let me show you why generics exist and when you actually need them.


Part 1: The Problem Generics Solve

Imagine you’re building a simple data fetching function. Let’s start without generics:

function fetchUser(id: number) {
  return fetch(`/api/users/${id}`).then(res => res.json());
}

function fetchPost(id: number) {
  return fetch(`/api/posts/${id}`).then(res => res.json());
}

function fetchComment(id: number) {
  return fetch(`/api/comments/${id}`).then(res => res.json());
}

Question: What’s wrong with this code?

Answer:

  1. Lots of repetition
  2. No type safety - .json() returns any
  3. Have to write new function for every resource type

Let’s try consolidating:

function fetchData(url: string) {
  return fetch(url).then(res => res.json());
}

// Usage
const user = await fetchData('/api/users/1'); // Type: any 😞
const post = await fetchData('/api/posts/1'); // Type: any 😞

Better? Not really. We lost all type information.

This is exactly the problem generics solve: We need a way to write flexible functions while maintaining type safety.


Part 2: Your First Generic Function

Here’s the same function with generics:

function fetchData<T>(url: string): Promise<T> {
  return fetch(url).then(res => res.json());
}

// Now we can specify what we're fetching
interface User {
  id: number;
  name: string;
  email: string;
}

const user = await fetchData<User>('/api/users/1');
// ✅ user is typed as User, not any!

Let’s break down the syntax:

function fetchData<T>(url: string): Promise<T>
//              ^^^                        ^^^
//               |                          |
//          Type parameter           Return type uses T

Think of <T> as a type variable - similar to how you’d use a regular variable:

// Regular variable
function greet(name: string) {
  return `Hello, ${name}`;
}

// Type variable
function fetchData<T>(url: string): Promise<T> {
  return fetch(url).then(res => res.json());
}

Just like name is a placeholder for actual string values, T is a placeholder for actual types.


Part 3: When TypeScript Can Infer Generics

Often, you don’t need to specify the generic type explicitly:

function identity<T>(value: T): T {
  return value;
}

// TypeScript can infer T from the argument
const result = identity("hello");  // T is inferred as string
const num = identity(42);          // T is inferred as number

// You CAN be explicit if you want
const explicit = identity<string>("hello");

Rule of thumb: Let TypeScript infer when possible. Be explicit when necessary (we’ll see examples).


Part 4: Practical Example - React Hook

Let’s build a realistic custom hook that uses generics:

Without generics (bad):

function useApi(url: string) {
  const [data, setData] = useState<any>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
  
  return { data, loading, error }; // data is any 😞
}

// Usage
const { data } = useApi('/api/users/1');
data.name;  // No autocomplete, no type checking 😞

With generics (good):

function useApi<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then((data: T) => setData(data))
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);
  
  return { data, loading, error };
}

// Usage with type safety
interface User {
  id: number;
  name: string;
  email: string;
}

const { data, loading, error } = useApi<User>('/api/users/1');

if (data) {
  data.name;   // ✅ Autocomplete works!
  data.email;  // ✅ Type checking works!
  data.foo;    // ❌ Error: Property 'foo' does not exist
}

Notice: The generic <T> flows through the entire hook - from the state to the return type.


Part 5: Multiple Type Parameters

Sometimes you need more than one type parameter:

// Example: A key-value store
function createStore<K, V>() {
  const store = new Map<K, V>();
  
  return {
    get(key: K): V | undefined {
      return store.get(key);
    },
    set(key: K, value: V): void {
      store.set(key, value);
    },
    has(key: K): boolean {
      return store.has(key);
    }
  };
}

// Usage
const userStore = createStore<number, User>();
userStore.set(1, { id: 1, name: 'Alice', email: '[email protected]' });

const user = userStore.get(1);  // Type: User | undefined
const invalid = userStore.get('1'); // ❌ Error: string is not assignable to number

Multiple type parameters let you express relationships between different types in your function.


Part 6: Generic Constraints

Sometimes you need to restrict what types can be used. Enter constraints:

// Problem: We want to ensure T has an 'id' property
function findById<T>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);
  //                         ^^^^^^^^
  // ❌ Error: Property 'id' does not exist on type 'T'
}

Solution: Add a constraint

// Constraint: T must have an 'id' property
interface HasId {
  id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
  return items.find(item => item.id === id);  // ✅ Now it works!
}

// Usage
interface User extends HasId {
  name: string;
}

interface Post extends HasId {
  title: string;
}

const users: User[] = [{ id: 1, name: 'Alice' }];
const posts: Post[] = [{ id: 1, title: 'Hello' }];

findById(users, 1);  // ✅ Works
findById(posts, 1);  // ✅ Works

const numbers = [1, 2, 3];
findById(numbers, 1); // ❌ Error: number doesn't extend HasId

The syntax <T extends HasId> means: “T can be any type, as long as it has at least an ‘id’ property.”


Part 7: Real-World Example - Form Handler

Let’s put it all together with a practical form handling example:

// Generic form values type
type FormValues = Record<string, any>;

// Form state
interface FormState<T extends FormValues> {
  values: T;
  errors: Partial<Record<keyof T, string>>;
  touched: Partial<Record<keyof T, boolean>>;
}

// Form actions
interface FormActions<T extends FormValues> {
  setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
  setFieldError: (field: keyof T, error: string) => void;
  setFieldTouched: (field: keyof T, touched: boolean) => void;
  resetForm: () => void;
}

// The hook
function useForm<T extends FormValues>(
  initialValues: T
): [FormState<T>, FormActions<T>] {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
  
  const setFieldValue = <K extends keyof T>(field: K, value: T[K]) => {
    setValues(prev => ({ ...prev, [field]: value }));
  };
  
  const setFieldError = (field: keyof T, error: string) => {
    setErrors(prev => ({ ...prev, [field]: error }));
  };
  
  const setFieldTouched = (field: keyof T, touched: boolean) => {
    setTouched(prev => ({ ...prev, [field]: touched }));
  };
  
  const resetForm = () => {
    setValues(initialValues);
    setErrors({});
    setTouched({});
  };
  
  return [
    { values, errors, touched },
    { setFieldValue, setFieldError, setFieldTouched, resetForm }
  ];
}

// Usage - Full type safety!
interface LoginForm {
  email: string;
  password: string;
  rememberMe: boolean;
}

function LoginComponent() {
  const [form, actions] = useForm<LoginForm>({
    email: '',
    password: '',
    rememberMe: false
  });
  
  // All of these are type-safe:
  actions.setFieldValue('email', '[email protected]');     // ✅
  actions.setFieldValue('password', '12345');             // ✅
  actions.setFieldValue('rememberMe', true);              // ✅
  
  actions.setFieldValue('email', 123);                    // ❌ Error: number not assignable to string
  actions.setFieldValue('wrongField', 'value');           // ❌ Error: 'wrongField' not in LoginForm
  
  return (
    <form>
      <input
        value={form.values.email}
        onChange={e => actions.setFieldValue('email', e.target.value)}
      />
      {/* Full autocomplete and type checking! */}
    </form>
  );
}

What’s happening here:

  1. T extends FormValues - Ensures T is an object type
  2. keyof T - Gets all keys of T (’email’ | ‘password’ | ‘rememberMe’)
  3. T[K] - Gets the type of a specific field
  4. Partial<...> - Makes all properties optional (for errors/touched)

Part 8: Common Patterns Cheat Sheet

Pattern 1: Array Operations

// Get first element with correct type
function first<T>(array: T[]): T | undefined {
  return array[0];
}

const nums = [1, 2, 3];
const firstNum = first(nums);  // Type: number | undefined

const strings = ['a', 'b', 'c'];
const firstStr = first(strings);  // Type: string | undefined

Pattern 2: Promise Wrapper

async function retry<T>(
  fn: () => Promise<T>,
  maxAttempts: number
): Promise<T> {
  let lastError: Error;
  
  for (let i = 0; i < maxAttempts; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
    }
  }
  
  throw lastError!;
}

// Usage
const user = await retry(() => fetchData<User>('/api/users/1'), 3);
// Type: User

Pattern 3: Builder Pattern

class QueryBuilder<T> {
  private filters: Array<(item: T) => boolean> = [];
  
  where(predicate: (item: T) => boolean): this {
    this.filters.push(predicate);
    return this;
  }
  
  execute(items: T[]): T[] {
    return items.filter(item => 
      this.filters.every(filter => filter(item))
    );
  }
}

// Usage
interface Product {
  id: number;
  name: string;
  price: number;
  category: string;
}

const products: Product[] = [/* ... */];

const result = new QueryBuilder<Product>()
  .where(p => p.price > 100)
  .where(p => p.category === 'electronics')
  .execute(products);
// Type: Product[]

Part 9: When NOT to Use Generics

Don’t use generics if:

  1. The type is always the same
// ❌ Unnecessary generic
function addNumbers<T extends number>(a: T, b: T): T {
  return (a + b) as T;
}

// ✅ Just use number
function addNumbers(a: number, b: number): number {
  return a + b;
}
  1. You’re just avoiding ‘any’
// ❌ This doesn't add value
function log<T>(value: T): void {
  console.log(value);
}

// ✅ This is fine
function log(value: unknown): void {
  console.log(value);
}
  1. The relationship is too complex

If you need 5+ type parameters, reconsider your design.


Part 10: Debugging Generics

When generics don’t work as expected, use these techniques:

Technique 1: Hover in VS Code

Hover over the variable to see what TypeScript inferred:

const result = fetchData<User>('/api/users/1');
//    ^^^^^^ Hover to see: const result: Promise<User>

Technique 2: Explicit Type Annotations

// If inference fails, be explicit
const data: User = await fetchData<User>('/api/users/1');

Technique 3: Use TypeScript Playground

Copy your generic code to TypeScript Playground and experiment.


Summary: The Mental Model

Think of generics as function parameters for types:

// Regular function
function greet(name: string) {
  return `Hello, ${name}`;
}
greet("Alice");  // Pass value

// Generic function  
function identity<T>(value: T): T {
  return value;
}
identity<string>("Alice");  // Pass type AND value

Key takeaways:

  1. Generics let you write reusable code while maintaining type safety
  2. Use <T> for single type parameter, <K, V> for multiple
  3. Add constraints with extends when needed
  4. Let TypeScript infer types when possible
  5. Be explicit when inference fails or for clarity

Practice Exercise

Try converting this code to use generics:

// Before
function getUserById(id: number) {
  return fetch(`/api/users/${id}`).then(res => res.json());
}

function getPostById(id: number) {
  return fetch(`/api/posts/${id}`).then(res => res.json());
}

// Your turn: Create a single generic function
function getById<T>(resource: string, id: number): Promise<T> {
  return fetch(`/api/${resource}/${id}`).then(res => res.json());
}

// Usage
interface User { id: number; name: string; }
interface Post { id: number; title: string; }

const user = await getById<User>('users', 1);
const post = await getById<Post>('posts', 1);

Next Steps:

  • Practice with your own code
  • Read TypeScript handbook on generics
  • Look at popular libraries (React, Redux) to see real-world examples

Questions? Leave a comment below!