A generic is a type you pass as an argument. Instead of fixing a type when you write a function, you leave a hole that gets filled at the call site, and TypeScript tracks what went into the hole so the return type stays accurate.
That single idea is why identity<T>(v: T): T preserves types where identity(v: any): any destroys them. Everything below is that idea applied harder: constraining the hole, branching on what filled it, and transforming it.
Every example here compiles under TypeScript 5.9 with strict enabled.
What Are Generics?
A generic is a placeholder for a type that gets filled in at call time. Think of it like a function parameter, but for types:
// Without generics - loses type information
function identity(value: any): any {
return value;
}
// With generics - type is preserved
function identity<T>(value: T): T {
return value;
}
const result = identity(42); // result: number ✅
const str = identity("hello"); // str: string ✅
Generic Constraints
Use extends to restrict what types a generic can be:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30 };
getProperty(user, "name"); // string ✅
getProperty(user, "age"); // number ✅
getProperty(user, "role"); // ❌ Error: "role" not in type
Conditional Types
This is where things get powerful. Conditional types let you create types that change based on conditions:
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<number>; // false
The infer Keyword
infer lets you extract a type from within a conditional type:
// Extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type Result = ReturnType<() => Promise<string>>; // Promise<string>
// Unwrap a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;
type Resolved = Awaited<Promise<Promise<number>>>; // number
Conditional types distribute over unions
This is the generics behaviour that causes the most confusion, and it is worth internalising early. When the checked type is a naked type parameter and you hand it a union, TypeScript applies the conditional to each member separately and unions the results.
type ToArray<T> = T extends unknown ? T[] : never;
type Dist = ToArray<string | number>;
// string[] | number[] ...not (string | number)[]
That is usually what you want. When it isn't, wrap both sides in a tuple to stop the distribution:
type NoDist<T> = [T] extends [unknown] ? T[] : never;
type NotDist = NoDist<string | number>;
// (string | number)[]
The tuple wrapper is not a trick, it is the documented opt-out: distribution only happens over a naked type parameter, and [T] is no longer naked. If a conditional type is returning a union you did not expect, this is nearly always why.
[!TIP]
inferonly works inside theextendsclause of a conditional type. You can use it multiple times in a single conditional to extract multiple type parameters.
Mapped Types
Mapped types transform every property of a type:
// Make all properties optional (like TypeScript's built-in Partial<T>)
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
// Make all properties readonly
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};
// Transform values
type Stringify<T> = {
[K in keyof T]: string;
};
Remapping Keys
TypeScript 4.1+ allows remapping keys with as:
// Add a "get" prefix to every key
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = { name: string; age: number };
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }
Real-World Pattern: Type-Safe API Response
Here's a practical generic pattern for API responses:
type ApiResponse<T> =
| { success: true; data: T; error: null }
| { success: false; data: null; error: string };
async function fetchUser(id: string): Promise<ApiResponse<User>> {
try {
const user = await db.users.findById(id);
return { success: true, data: user, error: null };
} catch (e) {
return { success: false, data: null, error: (e as Error).message };
}
}
// Usage - TypeScript narrows the type automatically
const result = await fetchUser("123");
if (result.success) {
console.log(result.data.name); // User ✅
} else {
console.log(result.error); // string ✅
}
When inference goes wrong, and how to steer it
Most real generics pain is not writing the type, it is TypeScript inferring something wider or narrower than you meant. Three tools fix nearly all of it.
const type parameters keep literals literal
By default TypeScript widens literals during inference, so an array argument becomes string[] and you lose the exact members. A const type parameter, added in TypeScript 5.0, preserves them without making the caller write as const:
function firstOf<const T extends readonly unknown[]>(t: T): T[0] {
return t[0];
}
const f = firstOf(['a', 'b']);
// 'a', not string
NoInfer stops one argument poisoning another
When a type parameter appears in several positions, TypeScript infers from all of them and unions the result, which is rarely what you want for a default or fallback value. NoInfer, added in 5.4, marks a position as "check against this, do not infer from it":
function assign<T>(a: T, b: NoInfer<T>): T {
return a ?? b;
}
assign<string | number>('x', 1);
// b is checked against string | number, but does not widen T
Before NoInfer existed, the workaround was an extra type parameter constrained to the first. The built-in is clearer and does not leak into the signature.
satisfies checks without widening
satisfies validates a value against a type while keeping the narrower inferred type, which is exactly what you want for configuration objects:
type Cfg = { host: string; port: number };
const cfg = { host: 'localhost', port: 5432 } satisfies Cfg;
const h: string = cfg.host; // still the literal type, still checked
Annotating const cfg: Cfg instead would have thrown away the literal types. This matters the moment you want to index into the object by key.
Variance annotations, and when you need them
TypeScript infers variance for you, and for most code you should let it. The in and out annotations from 4.7 exist for two reasons: to document intent, and to short-circuit variance computation on large recursive types where inference is measurably slow.
interface Box<in out T> {
get(): T;
set(v: T): void;
}
out T means T only appears in output positions (covariant), in T means input only (contravariant), and in out means both, which forces invariance. Getting one wrong is a compile error rather than a silent behaviour change, so they are safe to add. Do not reach for them until a profile says type-checking is slow.
When not to use a generic
A generic that appears exactly once in a signature is not doing anything. This is the most common misuse:
// Pointless: T is used once, so it is just `unknown` with extra steps
function log<T>(value: T): void {
console.log(value);
}
// Say what you mean
function log(value: unknown): void {
console.log(value);
}
The rule of thumb: a type parameter earns its place when it appears at least twice, because its job is to relate two positions. Relating an argument to a return type, or one argument to another. If it does not relate anything, delete it.
Similarly, resist the deeply nested conditional type when a function overload or a plain union would read better. Recursive conditional types also hit an instantiation depth limit, and the error when you hit it is famously unhelpful.
The three errors you will actually hit
These are the exact messages, reproduced under TypeScript 5.9 with strict.
"'T' could be instantiated with an arbitrary type"
Type 'string' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type which could be unrelated to 'string'.
Thrown by code like function bad<T>(x: T): T { return "nope"; }. It reads like a compiler quirk and it is not: the caller chooses T, so returning a string is only valid if the caller happened to pick string. They might pick number. The fix is almost never a cast, it is admitting the function does not actually work for all T. Either constrain it (T extends string) or stop making it generic.
"Type 'X' is not assignable to type 'Y'" on an invariant generic
Type 'Ctr<string>' is not assignable to type 'Ctr<string | number>'.
Type 'string | number' is not assignable to type 'string'.
A Ctr<string> is not a Ctr<string | number> when T is invariant, because someone holding the wider reference could write a number into a box that a string reader still points at. Notice the second line reverses the direction: that reversal is the tell that you are looking at a variance problem rather than a plain mismatch.
"Argument of type '"b"' is not assignable to parameter of type '"a"'"
Argument of type '"b"' is not assignable to parameter of type '"a"'.
The K extends keyof T pattern working exactly as designed. keyof { a: number } is the literal union "a", so "b" is rejected. When this fires unexpectedly, it usually means T was inferred more narrowly than you intended, and a const type parameter or an explicit type argument fixes it.
Built-in Utility Types
TypeScript ships with these generic utility types. Learn them:
| Type | Description |
|---|---|
Partial<T> | All properties optional |
Required<T> | All properties required |
Readonly<T> | All properties readonly |
Pick<T, K> | Keep only keys K |
Omit<T, K> | Remove keys K |
Record<K, V> | Object with keys K and values V |
ReturnType<T> | Return type of a function |
Awaited<T> | Unwrap a Promise |
NonNullable<T> | Remove null and undefined |
Summary
Generics unlock a level of type safety that makes your code self-documenting and refactor-proof. The pattern progression:
- Basic generics:
<T>as a type placeholder - Constraints:
extendsto restrict T - Conditional types:
T extends X ? A : B, remembering they distribute over unions infer: extract types from patterns- Mapped types: transform every key of T
- Steering inference:
constparameters,NoInfer, andsatisfies
Two rules carry most of the value. A type parameter earns its place only when it appears at least twice, because its job is to relate two positions. And when a conditional type returns a union you did not expect, it distributed, so wrap it in a tuple.
Start in your API layer, where the payoff is largest, and you will not want to go back to any.
Written by
Jamith NimanthaSoftware developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.
Related Articles
All articles →Getting Started with Next.js 16: A Complete Guide
Everything you need to know to build fast, modern web applications with Next.js 16 App Router, Server Components, and TypeScript. From project setup to production deployment.
React Server Components in Depth: What They Are and When to Use Them
React Server Components fundamentally change how we think about rendering. This guide breaks down how they work, how they differ from Client Components, and the patterns that will make your Next.js apps faster.
Stop Writing API Wrappers. Use TanStack Query Instead
Most frontend codebases have a homegrown API layer full of useEffect hacks, loading booleans, and stale data bugs. TanStack Query solves all of these in 20 lines. Here's how to migrate.