Generics in TypeScript are one of those things that click suddenly or not at all. I spent months copy-pasting generic syntax without understanding it. Then I refactored a data fetching hook and everything made sense.

Here’s the path that worked for me, skip the academic explanations.

Start with the problem

We had a bunch of API fetching hooks that looked like this:

function useUserData() {
  const [data, setData] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch('/api/user')
      .then(r => r.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return { data, loading };
}

function usePostsData() {
  const [data, setData] = useState<Post[] | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch('/api/posts')
      .then(r => r.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  return { data, loading };
}

function useCommentsData() {
  // ... you get the idea
}

Same logic, different types. Classic duplication. First instinct: make it a function.

function useFetchData(url: string) {
  const [data, setData] = useState(null);  // 🤔 What type?
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading };
}

Problem: data is any. TypeScript can’t know what /api/user returns vs /api/posts. This compiles but defeats the whole point of TypeScript.

Enter generics

This is where generics actually solve a real problem:

function useFetchData<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(url)
      .then(r => r.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading };
}

// Usage
const { data } = useFetchData<User>('/api/user');
// data is User | null ✅

const { data: posts } = useFetchData<Post[]>('/api/posts');
// posts is Post[] | null ✅

The <T> is a type variable. Think of it as a parameter, but for types instead of values. When you call useFetchData<User>, you’re saying “T is User in this case.”

That’s it. That’s generics. The rest is just variations on this pattern.

Generic constraints

Sometimes you want to limit what types are allowed. Say we want to ensure the data has an id field:

interface HasId {
  id: string;
}

function useFetchData<T extends HasId>(url: string) {
  // ... same implementation

  // Now we can safely do this:
  const sortById = (items: T[]) => items.sort((a, b) =>
    a.id.localeCompare(b.id)  // ✅ TypeScript knows T has .id
  );

  return { data, loading, sortById };
}

// This works
useFetchData<User>('/api/user');  // User has id: string

// This fails
useFetchData<string>('/api/whatever');  // string doesn't have id

T extends HasId means “T can be any type, as long as it has an id property.”

TypeScript code in editor

Multiple type parameters

You can have more than one:

function usePaginatedData<T, M = never>(
  url: string,
  transform?: (raw: M) => T
) {
  const [data, setData] = useState<T | null>(null);

  useEffect(() => {
    fetch(url)
      .then(r => r.json())
      .then(raw => {
        const transformed = transform ? transform(raw) : raw;
        setData(transformed);
      });
  }, [url, transform]);

  return { data };
}

// Usage
interface RawApiResponse {
  items: Post[];
  metadata: { count: number };
}

const { data } = usePaginatedData<Post[], RawApiResponse>(
  '/api/posts',
  (raw) => raw.items  // transform RawApiResponse -> Post[]
);

M = never means the second type parameter is optional. If you don’t provide transform, M defaults to never and isn’t used.

Generic utility types

These are built into TypeScript and super useful:

interface User {
  id: string;
  email: string;
  password: string;
  createdAt: Date;
}

// Pick certain properties
type PublicUser = Pick<User, 'id' | 'email'>;
// { id: string; email: string }

// Omit certain properties
type UserWithoutPassword = Omit<User, 'password'>;
// { id: string; email: string; createdAt: Date }

// Make all properties optional
type PartialUser = Partial<User>;
// { id?: string; email?: string; ... }

// Make all properties required (opposite of Partial)
type RequiredUser = Required<PartialUser>;
// back to User

// Make all properties readonly
type ImmutableUser = Readonly<User>;
// Can't modify any property after creation

These combine really well:

function updateUser(
  id: string,
  updates: Partial<Omit<User, 'id' | 'createdAt'>>
) {
  // updates can have email and/or password, but not id or createdAt
}

updateUser('123', { email: '[email protected]' });  // ✅
updateUser('123', { id: '456' });  // ❌

Generic React components

This pattern shows up all the time:

interface SelectProps<T> {
  options: T[];
  value: T;
  onChange: (value: T) => void;
  getLabel: (option: T) => string;
}

function Select<T>({ options, value, onChange, getLabel }: SelectProps<T>) {
  return (
    <select
      value={getLabel(value)}
      onChange={e => {
        const selected = options.find(o => getLabel(o) === e.target.value);
        if (selected) onChange(selected);
      }}
    >
      {options.map(option => (
        <option key={getLabel(option)} value={getLabel(option)}>
          {getLabel(option)}
        </option>
      ))}
    </select>
  );
}

// Works with any type
<Select
  options={users}
  value={selectedUser}
  onChange={setSelectedUser}
  getLabel={u => u.name}
/>

<Select
  options={['apple', 'banana', 'orange']}
  value={selectedFruit}
  onChange={setSelectedFruit}
  getLabel={fruit => fruit}
/>

Same component, different types. The generic T lets TypeScript ensure value, onChange, and options all agree on the type.

When NOT to use generics

Don’t use generics just because you can:

// ❌ Overengineered
function add<T extends number>(a: T, b: T): T {
  return (a + b) as T;
}

// ✅ Just use the type directly
function add(a: number, b: number): number {
  return a + b;
}

If you’re only ever using one type, don’t make it generic. Generics add complexity. Only use them when you need to support multiple types with the same logic.

The mental model

Think of generics as a way to say “I don’t know what type this will be yet, but once you tell me, I’ll make sure everything is consistent.”

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

identity<number>(42);  // "T is number"
identity<string>("hello");  // "T is string"

TypeScript substitutes the type parameter everywhere T appears. It’s like find-and-replace for types.

Advanced pattern: inferring types

Sometimes TypeScript can figure out the generic type from context:

function map<T, U>(array: T[], fn: (item: T) => U): U[] {
  return array.map(fn);
}

// TypeScript infers T = number, U = string
const result = map([1, 2, 3], n => n.toString());
// result: string[]

You don’t need to write map<number, string> - TypeScript sees you passed number[] and infers T = number. Then it sees your function returns a string, so U = string.

What actually helped me learn

  1. Start with duplication. Write the code 2-3 times with different types. Then factor out the generic version. Don’t try to write it generic first.

  2. Use descriptive type parameter names. <T> is fine for tutorials, but <TData>, <TResponse>, <TProps> make code clearer.

  3. Test the types. Use TypeScript’s hover tooltip (in VS Code) to verify the inferred types match what you expect.

  4. Read library code. React’s types, especially useState and useEffect, are great examples of practical generic usage.

The docs are thorough but dry. This stuff clicked for me when I had a real problem to solve. Find your own problem, solve it without generics first, then refactor to use them. Way better than trying to understand abstract examples.