Skip to content
Muhammet Şafak
tr
Languages 5 min read

Generics and type inference in TypeScript

Building reusable, type-safe abstractions with generics in TypeScript and making the most of type inference.


When I first switched to TypeScript, I actively avoided generics. They looked complicated; I figured I’d never need to go beyond the built-in usages like Array<T> or Promise<T>. Working on a larger codebase quickly changed that view. Understanding generics is what takes you from “JavaScript with type annotations” to code with real type safety.

What is a generic

A generic lets a function or class defer the type it works with until the call site. It’s the way to build reusable structures without giving up type safety (like any forces you to) and without rewriting the same logic for every type.

Let’s start with the simplest example:

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

const num = identity(42);        // T is inferred as number
const str = identity("hello");   // T is inferred as string

T is a placeholder of sorts. When the function is called, TypeScript figures out what type was passed and resolves T concretely.

Type inference and when it works for you

One of TypeScript’s strengths is that you usually don’t need to specify the generic parameter explicitly. Type inference lets the compiler figure it out for you.

// Explicit type parameter:
const arr = identity<number[]>([1, 2, 3]);

// With inference — the compiler already knows:
const arr = identity([1, 2, 3]);

This keeps code that uses generics from becoming overly verbose. When you’re both the library author and the consumer, the difference becomes very clear.

Inference has its limits, though. If the compiler can’t gather enough information from the context — for example, when a function is called without parameters or when the return type is tied to another generic — you have to write the type yourself. Ignoring that situation can lead to unknown or an unintended widening you didn’t ask for.

Constraining generics with extends

Sometimes allowing T to be absolutely anything is too broad. The extends keyword lets you specify what type the generic must conform to:

interface HasId {
    id: number;
}

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

const users = [
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" },
];

const user = findById(users, 1); // T => { id: number; name: string }

By saying T extends HasId we’re declaring “this generic only works for types that have an id field.” TypeScript knows that accessing item.id is safe.

This constraint also makes error messages meaningful. If you try to pass an object without an id field, the compiler tells you exactly which requirement wasn’t met. If you’d used any, you’d only see that error at runtime — and probably much later than you’d like.

Real-world usage: API response wrapper

One pattern I reach for constantly in projects: a single generic type to wrap API responses.

interface ApiResponse<T> {
    data: T;
    success: boolean;
    message: string | null;
}

async function fetchUser(id: number): Promise<ApiResponse<User>> {
    const response = await fetch(`/api/users/${id}`);
    return response.json();
}

async function fetchOrders(userId: number): Promise<ApiResponse<Order[]>> {
    const response = await fetch(`/api/users/${userId}/orders`);
    return response.json();
}

ApiResponse<T> is defined once; each endpoint just swaps in a different type for T. Instead of rewriting the shared structure everywhere, you rely on a single generic type.

Something I noticed when putting this into practice: the ApiResponse<T> contract makes it much easier to catch mistakes during client-server integration. When the server adds a field and changes the response type, the generic structure gives you a compile error at every consumer. Instead of code that silently runs but processes incorrect data, you get a compiler that warns you upfront.

Multiple generic parameters

Sometimes you need more than one type parameter:

function mapObject<K extends string, V, R>(
    obj: Record<K, V>,
    transform: (value: V, key: K) => R
): Record<K, R> {
    const result = {} as Record<K, R>;
    for (const key in obj) {
        result[key] = transform(obj[key], key);
    }
    return result;
}

const prices = { apple: 5, pear: 8, cherry: 15 };
const discounted = mapObject(prices, (price) => price * 0.9);
// discounted: Record<string, number>

One thing I pay attention to when using multiple generic parameters: clarify the relationships between them through constraints. K extends string here guarantees that K is a valid object key. Without it, TypeScript infers a broader type and can miss certain errors.

When to use generics vs. union types

Generics aren’t the right tool everywhere. Union types like string | number are a better fit for a fixed, known list of alternatives. Generics are for the situation where “I don’t know what type will come in, but whatever it is, I want consistency.”

My rule of thumb: if the caller decides what type to pass, use a generic; if the library decides what types it accepts, use a union.

It’s also worth watching out for generic overuse. Adding generics to every function just to be “flexible” makes it harder for readers to follow the type flow. If a generic isn’t adding value — no type inference happening, no constraint being enforced, return type not tied to the input type — a plain parameter type is almost certainly more readable.

Once you internalize generics, TypeScript’s type system stops feeling like a constraint and starts feeling like a genuine tool. Well-designed generics in utility functions, hooks, and API layers in particular pay serious dividends in reduced refactoring costs down the road.

Tags: #TypeScript
Share:

Comments

Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.

Related Posts

Search the site

Start typing to search posts, projects and pages.

Esc to close Powered by Pagefind