Skip to content
All posts
Tutorial10 min read

Why TypeScript Generics Are More Powerful Than You Think

J
Jamith Nimantha
April 15, 2026
Close-up of lines of source code on a monitor
Photo by Unsplash
On this page

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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
// 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.

typescript
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:

typescript
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] infer only works inside the extends clause 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:

typescript
// 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:

typescript
// 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:

typescript
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:

typescript
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":

typescript
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:

typescript
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.

typescript
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:

typescript
// 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"

text
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

text
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"'"

text
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:

TypeDescription
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:

  1. Basic generics: <T> as a type placeholder
  2. Constraints: extends to restrict T
  3. Conditional types: T extends X ? A : B, remembering they distribute over unions
  4. infer: extract types from patterns
  5. Mapped types: transform every key of T
  6. Steering inference: const parameters, NoInfer, and satisfies

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.

J

Written by

Jamith Nimantha

Software developer. Builds the DebuggerMe tools and writes about the things he runs into shipping them.

Share this post

Back to all posts

Related Articles

All articles →