Generics Basics
Write reusable code without losing type information
Imagine you want to write a function that returns whatever you pass to it — an identity function. You could write:
function identity(x: any): any {
return x;
}This works at runtime, but you've lost all type information. If you call identity(42), the return type is any, so the compiler won't catch mistakes like identity(42).toUpperCase(). You've traded type safety for reusability.
Generics solve this problem. They let you write code that works with any type while preserving precise type information. The identity function becomes:
function identity<T>(x: T): T {
return x;
}Now identity(42) returns number, identity("hello") returns string, and the compiler tracks the exact type through the function. This is the power of generics: abstraction without information loss.
Why generics matter
Without generics, you face a painful trade-off:
- Concrete types: Write
identityNumber(x: number): numberandidentityString(x: string): string. This is type-safe but not reusable — you need a separate function for every type. anyescape hatch: Writeidentity(x: any): any. This is reusable but not type-safe — the compiler can't help you anymore.
Generics give you the best of both worlds: one function, full type safety, and the compiler infers the type parameter based on what you pass in.
The <T> is a type parameter (also called a type variable). It's a placeholder for "whatever type you pass in." When you call identity(42), TypeScript infers T = number and the return type becomes number. When you call identity("hello"), it infers T = string and the return type becomes string.
Type parameter naming
By convention, single-letter names like T, U, V are used for generic type parameters. If the parameter has a more specific role, use a descriptive name like TKey or TValue. Avoid overly generic names like Type or GenericType.
Generic functions
Let's look at more realistic examples. Suppose you want a function that wraps a value in an array:
The type parameter T flows through the signature: the input is T, the output is T[]. The compiler infers T from the argument you pass, so you get precise types at every call site.
Multiple type parameters
You can have more than one type parameter:
Here, A and B are independent type parameters. The compiler infers both from the arguments you pass. This is useful for functions that relate two different types (like a key-value pair, a tuple, or a mapping function).
Explicit type arguments
Usually, TypeScript infers type parameters from the arguments. But you can also provide them explicitly:
When a generic function has no parameters (or the parameters don't help inference), you must supply the type argument explicitly in angle brackets: createArray<number>().
Generic interfaces and type aliases
Generics aren't limited to functions. You can parameterize interfaces and type aliases too.
The Box<T> interface is parameterized by T. When you create a Box<number>, T is number. When you create a Box<string>, T is string. The same interface works for any type.
The Result<T, E> type alias is even more interesting: it's a discriminated union parameterized by two types — the success value type (T) and the error type (E). This is a common pattern for modeling operations that can succeed or fail.
Reusable abstractions
Generic interfaces and type aliases let you define reusable abstractions — containers, results, events, state machines — that work with any payload type. You write the logic once and reuse it everywhere.
Generic classes
Classes can also be generic. Here's a simple generic stack:
The Stack<T> class holds items of type T. When you instantiate new Stack<number>(), all the methods operate on numbers. When you instantiate new Stack<string>(), all the methods operate on strings. One class, many types.
A practical example: map
Let's build a generic map function (like Array.prototype.map). It takes an array of type T[] and a function (T) => U, and returns an array of type U[].
Notice how the compiler infers both T and U:
- From
numbers(anumber[]), it infersT = number. - From the callback
n => n * 2(which returns anumber), it infersU = number. - So the return type is
number[].
For the second call, it infers T = number and U = string (from the callback returning a string), so the return type is string[].
This is the power of generics: you write one function, and the compiler figures out the specific types for every call site.
Type parameter flow
Understanding how type parameters flow through a function is key to using generics effectively. Let's trace through an example:
The type parameter T is inferred from the input array. If you pass number[], T = number, so the return type is number | undefined. The | undefined is there because the array might be empty.
Challenge: Generic container
Now it's your turn. You'll implement a generic Container class that holds a value and provides methods to transform it.
Implement a generic Container<T> class with:
- A constructor that takes an initial value of type
T. - A
get()method that returns the current value. - A
map<U>(fn: (value: T) => U)method that appliesfnto the value and returns a newContainer<U>(do not mutate the original).
The tests will verify that map transforms the value and returns a new container with the correct type.
Multiple choice check
Given this generic function:
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
What is the return type of getFirst([1, 2, 3])?
T
number | undefined
number
When to use generics
Generics are the right choice when:
- You're writing a utility that works with any type. Functions like
identity,wrap,first,last,map,filter, etc. - You're defining a container or collection.
Box<T>,Array<T>,Set<T>,Map<K, V>, etc. - You're modeling results, events, or state machines with variable payloads.
Result<T, E>,Event<TPayload>,State<TData>, etc. - You want to preserve type information through a transformation. A function that takes
T[]and returnsU[](likemap), or a class that takesTand exposesT(likePromise<T>).
Don't use generics when:
- The type is genuinely
anyorunknownand you don't care about precision. - You're only ever going to use it with one concrete type. (Though making it generic anyway can be fine for future-proofing.)
Start concrete, refactor to generic
If you're unsure whether you need generics, start with a concrete type (e.g., Box that holds number). If you later need it for other types, refactor to Box<T>. Don't prematurely genericize — but don't be afraid to add type parameters when the need arises.
Recap
Generics let you write reusable code that works with any type while preserving full type information. Instead of choosing between concrete types (verbose, not reusable) and any (reusable, not type-safe), you use type parameters to abstract over types.
Key takeaways:
- Type parameters (like
<T>) are placeholders for "whatever type you pass in." - Inference: The compiler usually infers type parameters from the arguments you pass. You rarely need to write them explicitly.
- Generic functions:
function identity<T>(x: T): T { return x; }— the parameter and return type are bothT. - Generic interfaces and type aliases:
interface Box<T> { value: T }— parameterize data structures. - Generic classes:
class Stack<T> { /*...*/ }— one class, many types. - Multiple type parameters:
function pair<A, B>(a: A, b: B): [A, B]— relate different types.
Generics are everywhere in TypeScript: Array<T>, Promise<T>, Map<K, V>, Set<T>, and countless libraries. Once you understand how they work, you'll start seeing opportunities to write more reusable, type-safe code everywhere.
Next: Generic Constraints, where we go beyond unconstrained type parameters and explore how to restrict T to types that have certain properties — unlocking even more powerful abstractions like keyof, extends, and conditional types.