In the previous tutorial, we learned about type narrowing and type guards. Now let’s learn about generics — one of the most powerful features in TypeScript.
By the end of this tutorial, you will know how to write generic functions, interfaces, and constraints. You will also learn common patterns like keyof, default type parameters, and generic utility functions.
Why Generics?
Imagine you need a function that returns the first element of any array:
function firstNumber(arr: number[]): number | undefined {
return arr[0];
}
function firstString(arr: string[]): string | undefined {
return arr[0];
}
This works, but you need a separate function for every type. You could use any:
function first(arr: any[]): any {
return arr[0];
}
const num = first([1, 2, 3]); // type is any — not helpful
const str = first(["a", "b"]); // type is any — not helpful
But now you lose type information. TypeScript does not know what first returns.
Generics solve this. They let you write one function that works with any type while keeping the type information:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = first([1, 2, 3]); // type is number | undefined
const str = first(["a", "b"]); // type is string | undefined
The <T> is a type parameter. It is a placeholder that gets replaced with the actual type when you call the function. TypeScript infers T from the arguments.
Generic Functions
The basic syntax is <T> after the function name:
function identity<T>(value: T): T {
return value;
}
const a = identity("hello"); // T is string
const b = identity(42); // T is number
const c = identity(true); // T is boolean
You can also specify the type explicitly:
const d = identity<string>("hello"); // Explicit: T is string
Usually you let TypeScript infer the type. Specify it explicitly only when inference does not work or you want to be extra clear.
Arrow Function Syntax
const identity = <T>(value: T): T => value;
// In .tsx files (React), use this syntax to avoid confusion with JSX:
const identity = <T,>(value: T): T => value;
Multiple Type Parameters
Use multiple type parameters when you need more than one generic type:
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair("hello", 42); // [string, number]
const p2 = pair(true, [1, 2, 3]); // [boolean, number[]]
Another example — a map function:
function mapArray<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
const numbers = [1, 2, 3];
const strings = mapArray(numbers, (n) => n.toString());
// strings is string[]
Generic Interfaces
Interfaces can be generic too:
interface Box<T> {
value: T;
}
const numberBox: Box<number> = { value: 42 };
const stringBox: Box<string> = { value: "hello" };
A more practical example — a generic API response:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
interface User {
id: number;
name: string;
}
interface Product {
id: number;
name: string;
price: number;
}
const userResponse: ApiResponse<User> = {
data: { id: 1, name: "Alex" },
status: 200,
message: "OK",
};
const productResponse: ApiResponse<Product> = {
data: { id: 1, name: "Laptop", price: 999 },
status: 200,
message: "OK",
};
One interface works for any data type. You do not need UserResponse, ProductResponse, etc.
Generic Type Aliases
Type aliases can be generic too:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { ok: false, error: "Cannot divide by zero" };
}
return { ok: true, value: a / b };
}
const result = divide(10, 3);
if (result.ok) {
console.log(result.value); // 3.333...
} else {
console.log(result.error);
}
This Result pattern is common in TypeScript. It is similar to Rust’s Result<T, E> and Go’s (value, error) return pattern.
Generic Constraints
Sometimes you need to restrict what types can be used with a generic. Use extends to add constraints:
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
getLength("hello"); // OK — string has length
getLength([1, 2, 3]); // OK — array has length
getLength({ length: 5 }); // OK — has length property
getLength(42); // Error: number does not have 'length'
The constraint T extends { length: number } means: T must be a type that has a length property of type number.
Constraining with Interfaces
interface Printable {
toString(): string;
}
function print<T extends Printable>(value: T): void {
console.log(value.toString());
}
print("hello"); // OK
print(42); // OK — number has toString()
print({ toString: () => "custom" }); // OK
The keyof Operator
keyof creates a union type of all property names of a type:
interface User {
name: string;
age: number;
email: string;
}
type UserKeys = keyof User; // "name" | "age" | "email"
keyof with Generics
The most common use of keyof is with generics to create type-safe property access:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alex", age: 25, email: "alex@example.com" };
const name = getProperty(user, "name"); // type is string
const age = getProperty(user, "age"); // type is number
getProperty(user, "phone"); // Error: "phone" is not in keyof User
This is incredibly useful. The function:
- Accepts only valid property names (caught at compile time)
- Returns the correct type for each property
Building a Type-Safe Pick Function
function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = obj[key];
}
return result;
}
const user = { name: "Alex", age: 25, email: "alex@example.com" };
const nameOnly = pick(user, ["name", "email"]);
// Type: { name: string; email: string }
Default Type Parameters
You can set default types for generic parameters:
interface Container<T = string> {
value: T;
}
const stringContainer: Container = { value: "hello" }; // T defaults to string
const numberContainer: Container<number> = { value: 42 }; // T is number
Another example with functions:
function createArray<T = string>(length: number, value: T): T[] {
return Array(length).fill(value);
}
const strings = createArray(3, "hello"); // T is string (inferred)
const numbers = createArray(3, 42); // T is number (inferred)
Default type parameters are useful for APIs where most callers use the same type but some need customization.
Common Generic Patterns
Generic Stack
class Stack<T> {
private items: T[] = [];
push(item: T): void {
this.items.push(item);
}
pop(): T | undefined {
return this.items.pop();
}
peek(): T | undefined {
return this.items[this.items.length - 1];
}
get size(): number {
return this.items.length;
}
}
const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.pop()); // 2
Generic Cache
class Cache<T> {
private store = new Map<string, { value: T; expiry: number }>();
set(key: string, value: T, ttlMs: number): void {
this.store.set(key, { value, expiry: Date.now() + ttlMs });
}
get(key: string): T | undefined {
const entry = this.store.get(key);
if (!entry || entry.expiry < Date.now()) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
}
const userCache = new Cache<{ name: string }>();
userCache.set("user-1", { name: "Alex" }, 60000);
console.log(userCache.get("user-1")); // { name: "Alex" }
Generic Event Emitter
type EventMap = Record<string, unknown>;
class EventEmitter<T extends EventMap> {
private listeners = new Map<keyof T, Set<(data: any) => void>>();
on<K extends keyof T>(event: K, callback: (data: T[K]) => void): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners.get(event)?.forEach((callback) => callback(data));
}
}
// Define your events
type AppEvents = {
userLoggedIn: { userId: number; name: string };
pageViewed: { url: string };
error: { message: string };
};
const emitter = new EventEmitter<AppEvents>();
emitter.on("userLoggedIn", (data) => {
console.log(`${data.name} logged in`); // data is fully typed
});
emitter.emit("userLoggedIn", { userId: 1, name: "Alex" });
Quick Practice
Create src/generics.ts and try these:
// Generic function
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
console.log(last([1, 2, 3])); // 3 (number | undefined)
console.log(last(["a", "b", "c"])); // "c" (string | undefined)
// Generic interface
interface Pair<T, U> {
first: T;
second: U;
}
const pair: Pair<string, number> = { first: "age", second: 25 };
// keyof constraint
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const users = [
{ name: "Alex", age: 25 },
{ name: "Sam", age: 30 },
];
console.log(pluck(users, "name")); // ["Alex", "Sam"]
console.log(pluck(users, "age")); // [25, 30]
// Generic with default
interface Response<T = null> {
status: number;
data: T;
}
const emptyResponse: Response = { status: 204, data: null };
const userResponse: Response<{ name: string }> = {
status: 200,
data: { name: "Alex" },
};
console.log(pair);
console.log(emptyResponse);
console.log(userResponse);
Run it:
npx tsx src/generics.ts
Output:
3
c
[ 'Alex', 'Sam' ]
[ 25, 30 ]
{ first: 'age', second: 25 }
{ status: 204, data: null }
{ status: 200, data: { name: 'Alex' } }
What is Next?
In the next tutorial, we will learn about classes and access modifiers — public, private, protected, abstract classes, and when to use classes vs plain objects.
Related Articles
- TypeScript Tutorial #8: Type Narrowing and Type Guards — previous tutorial in this series
- Go Tutorial: Goroutines — how Go handles generic-like patterns with interfaces
- Rust Tutorial: File I/O — Rust’s generics and trait bounds