Alright, let’s talk about testing the stuff that makes TypeScript, well, TypeScript: generics and complex types. If you’re testing a function that adds two numbers, you don’t need me. But when you have a function that can juggle string[], Promise<number>, or a Map<CustomerId, Partial<Invoice>> without breaking a sweat, your tests need to be just as clever. The good news? It’s not as scary as it looks. The bad news? You can’t just rely on type checking at compile time; you have to verify the behavior at runtime. That’s where we roll up our sleeves.

The core principle is this: You test the implementation, not the type signature. TypeScript’s type system, brilliant as it is, vanishes at runtime. Jest and Vitest are JavaScript tools; they don’t know what a T is. Your job is to create concrete examples that exercise the logic paths of your generic function for the various types you expect it to handle.

Testing the Generic Contract

Think of a generic function as making a promise. It promises to work correctly for any type T (or whatever letter you used) that meets certain conditions. Your test’s job is to validate that promise for a representative set of Ts.

Let’s say you have a simple generic stack:

class Stack<T> {
  private elements: T[] = [];

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

  pop(): T | undefined {
    return this.elements.pop();
  }

  peek(): T | undefined {
    return this.elements[this.elements.length - 1];
  }
}

You don’t test Stack<T>. You test Stack<number>, Stack<string>, and maybe Stack<SomeWeirdInterface>. The logic inside is identical for all types, so if it works for one, it should work for all. But you should test a few to be sure.

describe('Stack', () => {
  // A helper function to create and test a stack instance
  function testStackBasics<T>(createItem: (i: number) => T): void {
    it('should push, peek, and pop items in LIFO order', () => {
      const stack = new Stack<T>();
      const item1 = createItem(1);
      const item2 = createItem(2);

      stack.push(item1);
      stack.push(item2);

      expect(stack.peek()).toBe(item2); // Last in...
      expect(stack.pop()).toBe(item2);
      expect(stack.pop()).toBe(item1); // ...First out
      expect(stack.pop()).toBeUndefined();
    });
  }

  // Now run the same test with different type instantiations
  describe('with numbers', () => testStackBasics(i => i + 10));
  describe('with strings', () => testStackBasics(i => `item_${i}`));
  describe('with objects', () => testStackBasics(i => ({ id: i, value: 'test' })));
});

This pattern is powerful. You define the expected behavior once and then run it against different type scenarios. It thoroughly proves your generic logic is sound.

Wrestling with Complex Types and Mocking

This is where the “fun” begins. You might have a function that takes a generic constrained by a complex type, like a function that fetches users.

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

async function fetchUser<User extends { id: string; email: string }>(
  id: string
): Promise<ApiResponse<User>> {
  // ... some HTTP logic
}

To test this, you can’t just use a raw User type. You need to create a concrete mock that satisfies the constraint. This is where factories become your best friend.

// A factory function to create a consistent mock user.
// This is GOLD for keeping your tests DRY.
const createMockUser = (overrides?: Partial<{ id: string; email: string }>): { id: string; email: string } => ({
  id: 'test-id-123',
  email: 'test@example.com',
  ...overrides,
});

// Now, in your test, you're working with a concrete object.
test('fetchUser returns a valid ApiResponse', async () => {
  // This is the key part. We're defining what the 'mocked HTTP call' will return.
  // The type is now concrete: ApiResponse<{id: string, email: string}>
  const mockApiResponse: ApiResponse<{ id: string; email: string }> = {
    data: createMockUser({ id: 'user-1' }), // Use the factory
    status: 200,
  };

  // Mock your HTTP library (e.g., axios, fetch) to return this concrete object
  (axios.get as jest.Mock).mockResolvedValue({ data: mockApiResponse });

  const result = await fetchUser('user-1');

  expect(result).toEqual(mockApiResponse);
  expect(axios.get).toHaveBeenCalledWith('/users/user-1');
  expect(result.data.id).toBe('user-1'); // Specific property check
});

The beauty here is that createMockUser returns an object that fits the User constraint. The test is completely type-safe, and your mock data is consistent and reusable across every test that needs a user.

The Pitfall of Over-Mocking and Over-Specification

Here’s the most common mistake I see: people get so obsessed with perfect mocks that they test the mock, not the function. If your fetchUser function internally maps the API response to a different format, but your mock just returns the final expected format, your test is useless. It’s a tautology. You’re testing that your mock returns what your mock returns.

Your mocks should simulate the raw input your function receives, not the final output you expect. Test the transformation logic separately if you have to.

Also, don’t be the person who writes expect(typeof result.data.id).toBe('string'). You already did that with TypeScript! The type system has your back here. Use your tests to verify behavior and outcomes, not things the compiler already guarantees. Trust your types, and use your tests to catch the logic errors that sneak past them. That’s the real partnership.