Skip to content
Muhammet Şafak
tr
Languages 3 min read

Generics and type inference in TypeScript

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

Cover — a dark block engraved type Container<T> = { value: T; }, a line from T reaching a violet box tagged T -> 'VioletBlock' and an indigo sphere tagged T -> 'IndigoSphere'

Written 5 years ago — it reflects the conditions of that time.


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<"apple" | "pear" | "cherry", 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.

Experiments on this topic

A dice-free strategy core that resolves 480 hexes in 60 turns and runs offline in the browser; it shipped as Regnarium.

What it does today

Runs a full 4X match on a 480-hex map in at most 60 turns; rules, map generation and the opponent AI live in one pure TypeScript core, and combat rolls no dice. Anyone with half an hour and an appetite for strategy can open it in a browser.

Web app TypeScript Next.js React +2 more
September 2026 — September 2026

A swipe-based true/false trivia game; the real subject is not the game but making a client-computed score verifiable through a signature.

What it does today

The swipe-based true/false quiz runs end to end in its core loop as a mobile app; the score is produced on the client but verified server-side with a server_nonce and an HMAC signature. swipenor.com is the app's landing page, not the game itself.

Mobile app Laravel PHP PostgreSQL +6 more
July 2026 — Ongoing
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