TypeScript Generics
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: stringThe 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 | nullTypeScript 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 numberThe 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 propertyThe 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 userNotice 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 numberWithout 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
- Overusing any – Replace it with generics when you want flexibility plus safety.
- Not using generics when needed – If you are writing duplicate functions that differ only by type, stop and introduce
<T>. - Forgetting to declare the type parameter – Always add
<T>in the definition if you useTinside. - Assuming too much about T – If you need certain properties (like
.length), add a constraint withextends. - Manually specifying wrong types – Let inference work unless you have a good reason to override.
- A type parameter used only once – If
Tappears in exactly one spot, it creates no relationship between anything and a plain type is simpler. Generics earn their keep when the sameTlinks 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:
- Pick – rebuild
Pick<T, K>and use akeyofconstraint for real - Readonly – your first generic mapped type
- First of Array – the
getFirstElementexample, at the type level - Awaited – generic inference with
infer
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.
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