TypeScript Generics: The Beginner's Guide
A clear, runnable introduction to TypeScript generics — what they are, why they exist, how to write generic functions and types, and how constraints keep them honest.
What you'll learn
- ✓What a generic parameter actually is
- ✓How to write generic functions and read their signatures
- ✓How generic type aliases and interfaces work
- ✓Type-argument inference and when to be explicit
- ✓Constraints with extends and default type parameters
- ✓A few patterns you will see constantly in real code
Prerequisites
- •You can type ordinary functions — see Typing Functions
Generics are the last major idea in beginner TypeScript and the one that intimidates new learners the most. The syntax looks heavy at first — angle brackets, single capital letters, extends clauses — but the underlying concept is simple. A generic is a type with a hole in it, and the caller fills the hole. This post walks through every part of that idea with runnable examples.
The problem generics solve
Suppose you want a function that returns the first element of an array. Without generics, you might write three versions:
function firstNumber(items: number[]): number | undefined {
return items[0];
}
function firstString(items: string[]): string | undefined {
return items[0];
}
function firstUser(items: User[]): User | undefined {
return items[0];
}
Every version does exactly the same thing. The only difference is the type. You could collapse them into one with any:
function first(items: any[]): any {
return items[0];
}
…but now every caller throws away its type information. first(["a", "b"]) is typed as any, so the next line could do anything to it without complaint, and the compiler will miss every bug.
Generics give you the single function without the loss of safety:
function first<T>(items: T[]): T | undefined {
return items[0];
}
first([1, 2, 3]); // returns number | undefined
first(["a", "b", "c"]); // returns string | undefined
first<boolean>([true, false]); // returns boolean | undefined
The <T> declares a type parameter — a placeholder. The body uses T as if it were a real type. At each call site, the compiler infers what T should be from the arguments and substitutes it everywhere it appears.
That is the entire idea. The rest of the post is mechanics and patterns.
Reading generic syntax
The convention is single capital letters: T for “type,” U for a second type, K for “key,” V for “value,” E for “element.” There is nothing magical about the letters — function first<Item>(items: Item[]): Item | undefined { ... } is identical. Short letters survive because the meaning is obvious in context.
The angle brackets appear in two places:
- After the function name, to declare the parameter:
function first<T>(...). - At the call site, to specify the argument explicitly:
first<number>(...).
Specifying is usually unnecessary because the compiler infers it.
Inference
This is where generics start feeling pleasant. The compiler tries to figure out the type argument from the actual arguments:
function identity<T>(value: T): T {
return value;
}
const a = identity(42); // a: number — T inferred as number
const b = identity("hello"); // b: string — T inferred as string
const c = identity(true); // c: boolean
You do not need to write identity<number>(42). The compiler reads the argument, infers T = number, and propagates that through the return type.
When inference fails or you want to be explicit, you can supply the type argument by hand:
const set = new Set<number>();
set.add(1);
set.add("two"); // Error
new Set() on its own has nothing to infer from, so you tell it. Once specified, the rest of the API is locked to that type.
Multiple type parameters
You can declare as many type parameters as you need. They are independent:
function pair<A, B>(a: A, b: B): [A, B] {
return [a, b];
}
const p1 = pair(1, "one"); // [number, string]
const p2 = pair(true, [1, 2]); // [boolean, number[]]
You can also have one parameter depend on another via a constraint, which is the next topic.
Constraints with extends
A naked T can be anything. That is sometimes too loose — you might want to use .length on the value, which requires T to have a length property:
function longest<T>(a: T, b: T): T {
return a.length > b.length ? a : b; // Error: Property 'length' does not exist on type 'T'.
}
The compiler is right. T could be number, and numbers do not have .length. A constraint narrows what T is allowed to be:
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
longest("apple", "banana"); // ok — strings have length
longest([1, 2, 3], [4, 5]); // ok — arrays have length
longest(1, 2); // Error: number does not satisfy { length: number }
T extends X reads as “T is at least as specific as X.” Inside the body, you can rely on every member of X. Outside, the caller can still supply any subtype — longest("apple", "banana") returns a string, not { length: number }. The function preserves the exact type that came in.
This last point is the whole reason to use generics rather than (a: { length: number }, b: { length: number }). The generic version remembers what was passed in. The non-generic version forgets.
Try it yourself. Write function pick<T, K extends keyof T>(obj: T, key: K): T[K]. Call it with an object { name: "Ada", age: 30 } and the key "name". Confirm the editor shows the return type as string. Then pass "role" and read the error.
The keyof T in that exercise is one of the most useful constraints in real code — it says “K must be one of the keys of T.” T[K] then looks up that key’s type. Together they let you write fully typed property accessors.
Default type parameters
A type parameter can have a default, used when the caller does not supply one and the compiler cannot infer:
interface Box<T = string> {
value: T;
}
const a: Box = { value: "hello" }; // T defaults to string
const b: Box<number> = { value: 42 }; // T explicitly number
Defaults are most useful in library APIs where most users want the common case but a few need to customise.
Generic type aliases and interfaces
Generics are not just for functions. Any type alias or interface can declare type parameters:
type Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseNumber(input: string): Result<number> {
const n = Number(input);
if (Number.isNaN(n)) return { ok: false, error: "not a number" };
return { ok: true, value: n };
}
const r = parseNumber("42");
if (r.ok) {
console.log(r.value.toFixed(2)); // r.value is number here
} else {
console.log(r.error);
}
Result<T, E> is the workhorse error-handling type in many TypeScript codebases. It is just a discriminated union with two placeholders — generics give it a single definition that works for every operation.
A generic interface looks like this:
interface Stack<T> {
push(item: T): void;
pop(): T | undefined;
peek(): T | undefined;
readonly size: number;
}
function createStack<T>(): Stack<T> {
const items: T[] = [];
return {
push: (item) => { items.push(item); },
pop: () => items.pop(),
peek: () => items[items.length - 1],
get size() { return items.length; },
};
}
const numbers = createStack<number>();
numbers.push(1);
numbers.push(2);
console.log(numbers.pop()); // 2
console.log(numbers.size); // 1
The interface declares the shape parametrically. The function declares its own <T> and returns the matching Stack<T>. The two parameters are linked at the call site.
Generics on classes
The same pattern applies to classes:
class Queue<T> {
private items: T[] = [];
enqueue(item: T): void {
this.items.push(item);
}
dequeue(): T | undefined {
return this.items.shift();
}
get size(): number {
return this.items.length;
}
}
const q = new Queue<string>();
q.enqueue("first");
q.enqueue("second");
console.log(q.dequeue()); // "first"
The <T> is declared on the class and is in scope for every method and field.
A small worked example
A typed lookup function over a record, using everything in the post:
function getOrDefault<T extends object, K extends keyof T>(
obj: T,
key: K,
fallback: T[K],
): T[K] {
const value = obj[key];
return value === undefined || value === null ? fallback : value;
}
interface Settings {
theme: "light" | "dark";
fontSize: number;
notifications: boolean;
}
const settings: Partial<Settings> = { theme: "dark" };
const theme = getOrDefault(settings as Settings, "theme", "light");
const size = getOrDefault(settings as Settings, "fontSize", 14);
const notify = getOrDefault(settings as Settings, "notifications", true);
console.log(theme, size, notify);
T is constrained to object. K is constrained to keyof T. T[K] looks up the type of that key. The return type is exactly the type of the field, not a generic unknown — so theme is typed as "light" | "dark", size as number, and notify as boolean, all from one function definition.
Try it yourself. Without looking, write a swap<A, B>(pair: [A, B]): [B, A] function. Call it with [1, "hello"] and hover the result. Confirm it is [string, number]. Then change the constraint to <A extends number, B> and see how the call site changes.
A few real-world patterns
You will encounter these constantly in TypeScript codebases:
Array<T>— the standard library array.T[]is the same thing.Promise<T>— a value of typeTthat will arrive later.asyncfunctions always returnPromise<something>.Map<K, V>andSet<T>— the standard library collections, parameterised by their element types.Record<K, V>— an object whose keys areKand whose values areV. Equivalent to{ [key in K]: V }.Partial<T>— every field ofTmade optional. Used in the worked example above.Pick<T, K>andOmit<T, K>— subset and remove keys.
You do not need to memorise these. You only need to recognise that the angle brackets mean “a type with a hole — here is what to fill it with.”
When not to reach for generics
A common beginner mistake is over-generalising — adding <T> to functions that have a single concrete use case. A generic is justified when:
- The same code legitimately needs to work for multiple types, and
- The relationship between the parameter and the return is meaningful (the same
Tflows through).
If neither holds, plain types are clearer. Generics are a tool for preserving type information across a boundary. When there is no boundary, there is nothing to preserve.
Recap
You now know:
- A generic parameter is a type placeholder filled in by the caller — usually inferred from the arguments.
- The convention is single capital letters (
T,U,K,V), but any identifier works. - Constraints (
T extends X) restrict which types are allowed and unlock member access inside the body. keyof TandT[K]let you write functions that operate generically over keys and their values.- Generic type aliases, interfaces, and classes all use the same syntax.
- Standard library types like
Array<T>,Promise<T>,Map<K, V>, andRecord<K, V>are everyday generics. - Use generics when the same type genuinely needs to flow through a boundary. Skip them otherwise.
Next steps
You have now covered the core beginner curriculum for TypeScript: the language itself, the primitive types, interfaces and type aliases, function typing, and generics. From here, the natural directions are practical project work (typing a small Node.js API, a React app, or a CLI tool), and the type-level features used in advanced library code: conditional types, mapped types, template literal types, and the infer keyword.
A good next step in this series is a hands-on project using everything so far — we will pick that up in the upcoming posts.
→ Next: Start the practical projects (coming soon)
Questions or feedback? Email codeloomdevv@gmail.com.