TypeScript Generics

August 26, 20269 min read
Requirements:
FunctionsArraysObjects

Why generics make TypeScript click

You have probably written functions or classes that worked fine until you needed them to handle a slightly different type of data. Suddenly you either duplicate code or loosen type safety. Generics are TypeScript's way of letting you keep one clean implementation while adapting it to many data types without losing the compiler's help. Think of generics like a mold that can be filled with different materials but still keeps its shape.

Why not just use any

Using the any type feels like the easy way out. It accepts anything, so you can pass a string, a number, or an object and the compiler will not complain. The problem is that any also throws away all type information. That means no IntelliSense help and no warnings if you try to use a method that does not exist on the value. Generics give you the same flexibility but preserve the type, so the compiler knows exactly what you are working with.

Example:

function identityAny(arg: any): any {
  return arg
}
 
identityAny(1) // return type: any
identityAny('hello') // return type: any
 
// using a generic
function identityGeneric<T>(arg: T): T {
  return arg
}
 
identityGeneric(1) // return type: number
identityGeneric('hello') // return type: string

The second version remembers that if you pass in a number, you will get a number back. This is safer and clearer.

Writing a simple generic function

A common starter example is returning the first element from an array. Without generics you would either use any[] or duplicate the function for each type. With generics you can write it once:

function getFirstElement<T>(arr: T[]): T | null {
  return arr.length > 0 ? arr[0] : null
}
 
const firstNumber = getFirstElement([1, 2, 3]) // number | null
const firstName = getFirstElement(['Ada', 'Grace']) // string | null

TypeScript infers T from the argument, so calling getFirstElement([1, 2, 3]) means T is number automatically. If you try to pass a string array while forcing T to number, the compiler will stop you.

This exact function is the First of Array challenge, just at the type level. If the runtime version above makes sense, that challenge is a good next step.

Let inference do the work

You rarely need to write the type argument yourself. TypeScript fills it in from the values you pass:

function toPair<T>(a: T, b: T): [T, T] {
  return [a, b]
}
 
const inferred = toPair(1, 2) // [number, number] ✅ inferred
const explicit = toPair<string>('a', 'b') // [string, string] ✅ explicit
 
// toPair(1, 'two') // ❌ 'two' is not assignable to number

The failing call is interesting: because both parameters share the same T, TypeScript refuses to mix a number and a string. That is the whole point — the type parameter creates a relationship between values that any can never express.

Reach for an explicit type argument only when inference has nothing to work with, for example fetchJson<User>('/api/user') where the return type cannot be inferred from the arguments.

One syntax gotcha worth knowing: in a .tsx file, writing a generic arrow function as const identity = <T>(arg: T) => arg confuses the parser, because <T> looks like the start of a JSX tag. The common workaround is a trailing comma in the type parameter list, <T,>, or simply using a function declaration instead. In plain .ts files this problem does not exist.

Constraints: telling the compiler what T can do

A bare T could be anything, so TypeScript will not let you touch any properties on it. If your implementation needs a capability, declare it with extends:

function logLength<T extends { length: number }>(value: T): T {
  console.log(value.length) // ✅ safe, T is guaranteed to have length
  return value
}
 
logLength('hello') // ✅ strings have a length
logLength([1, 2, 3]) // ✅ arrays too
// logLength(42) // ❌ number has no length property

The most useful constraint in day-to-day code is keyof. It ties one type parameter to the keys of another:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key]
}
 
const user = { id: 1, name: 'Ada' }
 
const id = getProperty(user, 'id') // number
const userName = getProperty(user, 'name') // string
// getProperty(user, 'email') // ❌ 'email' does not exist on user

Notice the return type T[K]: not just "some property" but the exact type of the property you asked for. Typos in key names become compile errors instead of undefined at runtime.

Multiple type parameters

Nothing limits you to a single T. When two inputs vary independently, give each its own parameter. A function that zips keys and values together needs to track both types separately:

function zip<K, V>(keys: K[], values: V[]): Array<[K, V]> {
  return keys.map((key, i): [K, V] => [key, values[i]])
}
 
const entries = zip(['id', 'name'], [1, 2]) // Array<[string, number]>

K and V are inferred independently, so the keys stay strings and the values stay numbers all the way through. Single-letter names like T, K, and V are convention, not law — in bigger signatures, descriptive names such as TInput and TOutput read better.

The getProperty example from the constraints section is really the same idea: two type parameters where one (K) is constrained by the other (T). Once you see that combination, you will recognize it everywhere in library code.

A real-world example: a typed fetch wrapper

Most codebases have some version of this function. It is the place where generics earn their keep in everyday application code, not just in libraries:

async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url)
  return response.json() as Promise<T>
}
 
interface User {
  id: number
  name: string
}
 
async function loadUser(): Promise<void> {
  const user = await fetchJson<User>('/api/users/1')
  console.log(user.name) // ✅ fully typed, no casting at the call site
}

One cast lives inside the wrapper, and every caller gets a clean, typed result. This is also the rare case where you pass the type argument explicitly — nothing in the arguments tells TypeScript what the server returns, so inference has no material to work with.

Be honest about what this does: the type is a promise to the compiler, not a runtime check. If the API returns something else, the type system will not catch it. For payloads you do not control, pair the generic wrapper with runtime validation.

Default type parameters

Like function parameters, type parameters can have defaults. This keeps call sites short when there is a sensible fallback:

interface ApiResponse<T = unknown> {
  status: number
  data: T
}
 
const raw: ApiResponse = { status: 200, data: 'anything' } // T defaults to unknown
 
const typed: ApiResponse<{ id: number }> = {
  status: 200,
  data: { id: 1 },
}

unknown is a better default than any here: consumers of raw.data are forced to narrow it before use, while typed.data.id is fully typed. You will see this pattern in most API client libraries.

Generics with interfaces and types

Generics also work for object blueprints. Imagine a Person interface that can store different kinds of extra data per user:

interface Person<T> {
  id: number
  name: string
  data: T
}

This way, Person<string[]> might store a list of favorite colors, while Person<{ age: number }> stores structured stats. You still get full type checking on the data property.

Once you are comfortable parameterizing object shapes, mapped types take the idea further and let you transform one shape into another — that is how built-ins like Partial<T> and Readonly<T> work under the hood.

Generic classes

Classes take type parameters the same way. A queue is the classic example, because the type that goes in must be the type that comes out:

class Queue<T> {
  private items: T[] = []
 
  enqueue(item: T): void {
    this.items.push(item)
  }
 
  dequeue(): T | undefined {
    return this.items.shift()
  }
}
 
const numbers = new Queue<number>()
numbers.enqueue(1)
const next = numbers.dequeue() // number | undefined
// numbers.enqueue('two') // ❌ string is not assignable to number

Without the generic you would need a NumberQueue, a StringQueue, and so on — or an unsafe any queue where a string can sneak in between two numbers.

Common beginner mistakes and how to avoid them

  1. Overusing any – Replace it with generics when you want flexibility plus safety.
  2. Not using generics when needed – If you are writing duplicate functions that differ only by type, stop and introduce <T>.
  3. Forgetting to declare the type parameter – Always add <T> in the definition if you use T inside.
  4. Assuming too much about T – If you need certain properties (like .length), add a constraint with extends.
  5. Manually specifying wrong types – Let inference work unless you have a good reason to override.
  6. A type parameter used only once – If T appears in exactly one spot, it creates no relationship between anything and a plain type is simpler. Generics earn their keep when the same T links an input to an output.

A tiny utility pattern

If you find yourself wrapping values often, a simple generic helper keeps things DRY:

function wrapInArray<T>(value: T): T[] {
  return [value]
}

It works for numbers, strings, or even deeply nested complex objects, while always preserving the exact type of the wrapped elements.

Where to practice

Generics stick when you implement the utilities yourself instead of just consuming them:

From there, template literal types show how generics combine with string manipulation at the type level.

The takeaway

Generics in TypeScript are type placeholders that get replaced when you use the function, interface, or class. They keep your code reusable and your types intact. Start with inference, add extends constraints when your implementation needs a capability, and reach for default type parameters when there is a sensible fallback. Over time you will see that generics are not just a TypeScript feature but a mindset for writing adaptable and safe code.

If you want to see them in action, try rewriting a utility in your current project using <T> instead of any and notice how the compiler's feedback improves immediately.

Share this article

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges

Practice with Challenges

Put your typescript generics knowledge to the test with these related challenges.

#4Pick
Easy
#14First of Array
Easy
#189Awaited
Easy
#3312Parameters
Easy
#7Readonly
Easy
#2Get Return Type
Medium
#9Deep Readonly
Medium
#15Last of Array
Medium

Related Concepts

Concepts that build on or relate to typescript generics.

Mapped TypesUnion TypesInterfacesTemplate Literal Types

Common Errors

TypeScript errors you might encounter when working with typescript generics.

TS2322Fix TS2322: Type Not AssignableTS2344Fix TS2344: Type Does Not Satisfy the ConstraintTS2345Fix TS2345: Argument Type Not Assignable to ParameterTS2349Fix TS2349: This Expression Is Not CallableTS2416Fix TS2416: Property Not Assignable to Base TypeTS2769Fix TS2769: No Overload Matches This CallTS7006Fix TS7006: Parameter Implicitly Has 'any' Type