Episode 1 — Fundamentals / 1.25 — TypeScript Essentials
1.25.g — Generic Functions
In one sentence: Generics let you write functions that work with any type while preserving type safety — using type parameters like
<T>instead of sacrificing safety withany.
Navigation: ← 1.25 Overview · 1.25.h — Generic Interfaces and Constraints →
1. The problem generics solve
Without generics, you face a choice between type safety and reusability:
// Option 1: Type-safe but NOT reusable
function identityString(value: string): string { return value; }
function identityNumber(value: number): number { return value; }
function identityBoolean(value: boolean): boolean { return value; }
// You'd need a separate function for every type!
// Option 2: Reusable but NOT type-safe
function identity(value: any): any { return value; }
const result = identity("hello");
// result is 'any' — no autocomplete, no type checking
result.toUpperCase(); // No error even if result were a number
Generics give you both:
// Option 3: Type-safe AND reusable
function identity<T>(value: T): T {
return value;
}
const str = identity("hello"); // str is 'string'
const num = identity(42); // num is 'number'
const bool = identity(true); // bool is 'boolean'
str.toUpperCase(); // OK — TypeScript knows it's a string
num.toFixed(2); // OK — TypeScript knows it's a number
2. Generic syntax
The <T> is a type parameter — a placeholder for a type that will be determined when the function is called:
// ┌── Type parameter declaration
// │
function identity<T>(value: T): T {
// │ │
// └── Parameter type (uses T)
// └── Return type (uses T)
return value;
}
Tis a convention, not a keyword. You can name it anything:<Type>,<Item>,<Element>.Tstands for "Type" — it is the most common generic parameter name.
Calling generic functions
// Explicit type argument:
const result1 = identity<string>("hello"); // T = string
// Inferred type argument (preferred — less verbose):
const result2 = identity("hello"); // TypeScript infers T = string
const result3 = identity(42); // TypeScript infers T = number
3. Generic arrow functions
// Arrow function generic syntax:
const identity = <T>(value: T): T => value;
// In .tsx files, the <T> can look like a JSX tag. Add a comma or constraint:
const identity = <T,>(value: T): T => value;
// or:
const identity = <T extends unknown>(value: T): T => value;
4. Multiple type parameters
Functions can have multiple type parameters:
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[]]
// More descriptive names for clarity:
function map<Input, Output>(
items: Input[],
transform: (item: Input) => Output
): Output[] {
return items.map(transform);
}
const names = map([1, 2, 3], (n) => n.toString());
// names is string[] — TypeScript infers Input=number, Output=string
5. Type inference with generics
TypeScript is remarkably good at inferring generic type arguments from the values you pass — you rarely need to specify them explicitly:
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// TypeScript infers T from the argument:
const a = first([1, 2, 3]); // T = number, result = number | undefined
const b = first(["a", "b"]); // T = string, result = string | undefined
const c = first([true, false]); // T = boolean, result = boolean | undefined
// Inference with objects:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Alice", age: 30, email: "alice@example.com" };
const name = getProperty(user, "name"); // T = typeof user, K = "name", result = string
const age = getProperty(user, "age"); // K = "age", result = number
// getProperty(user, "foo"); // Error: '"foo"' is not assignable to keyof typeof user
6. Generic with arrays
One of the most common uses — functions that work with arrays of any type:
// Get the last element of any array
function last<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
last([1, 2, 3]); // number | undefined
last(["a", "b", "c"]); // string | undefined
// Reverse an array (return new array)
function reverse<T>(arr: T[]): T[] {
return [...arr].reverse();
}
reverse([1, 2, 3]); // number[] → [3, 2, 1]
reverse(["a", "b"]); // string[] → ["b", "a"]
// Filter by predicate
function filterBy<T>(arr: T[], predicate: (item: T) => boolean): T[] {
return arr.filter(predicate);
}
const adults = filterBy(
[{ name: "Alice", age: 30 }, { name: "Bob", age: 17 }],
(person) => person.age >= 18
);
// adults is { name: string; age: number }[]
// Remove duplicates
function unique<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
unique([1, 2, 2, 3, 3]); // number[] → [1, 2, 3]
unique(["a", "b", "a"]); // string[] → ["a", "b"]
// Group by a key
function groupBy<T>(arr: T[], keyFn: (item: T) => string): Record<string, T[]> {
const result: Record<string, T[]> = {};
for (const item of arr) {
const key = keyFn(item);
(result[key] ??= []).push(item);
}
return result;
}
const grouped = groupBy(
[{ dept: "eng", name: "Alice" }, { dept: "eng", name: "Bob" }, { dept: "hr", name: "Charlie" }],
(person) => person.dept
);
// { eng: [...], hr: [...] }
7. Generic with objects
// Pick a subset of properties
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: "Alice", age: 30, email: "alice@example.com" };
const nameOnly = pick(user, ["name"]);
// { name: string } — only 'name' property
const nameAndAge = pick(user, ["name", "age"]);
// { name: string; age: number }
// Merge two objects
function merge<T, U>(a: T, b: U): T & U {
return { ...a, ...b };
}
const merged = merge({ name: "Alice" }, { age: 30 });
// { name: string } & { age: number } = { name: string; age: number }
// Create a typed lookup function
function createLookup<T>(items: T[], keyFn: (item: T) => string): (key: string) => T | undefined {
const map = new Map<string, T>();
for (const item of items) {
map.set(keyFn(item), item);
}
return (key) => map.get(key);
}
const findUser = createLookup(
[{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }],
(user) => user.id
);
const alice = findUser("1"); // { id: string; name: string } | undefined
8. Built-in generic types
TypeScript's standard library uses generics extensively:
// Array<T> — same as T[]
const names: Array<string> = ["Alice", "Bob"];
// Promise<T> — represents a future value
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// Record<K, V> — object with keys of type K and values of type V
const scores: Record<string, number> = {
alice: 95,
bob: 87,
};
// Map<K, V> — typed Map
const cache = new Map<string, User>();
cache.set("alice", { id: 1, name: "Alice", email: "alice@example.com" });
// Set<T> — typed Set
const uniqueIds = new Set<number>();
uniqueIds.add(1);
uniqueIds.add(2);
// Readonly<T> — makes all properties readonly
const config: Readonly<{ host: string; port: number }> = {
host: "localhost",
port: 3000,
};
// config.host = "example.com"; // Error: read-only
// Partial<T> — makes all properties optional
function updateUser(id: number, updates: Partial<User>): void {
// 'updates' can have any subset of User properties
}
updateUser(1, { name: "New Name" }); // Only updating 'name'
9. Generic constraints (preview)
Sometimes you need to restrict what types T can be. Use extends to add constraints:
// T must have a 'length' property
function logLength<T extends { length: number }>(value: T): void {
console.log(value.length);
}
logLength("hello"); // OK — string has .length
logLength([1, 2, 3]); // OK — array has .length
logLength({ length: 5 }); // OK — object has .length
// logLength(42); // Error: number doesn't have .length
// T must extend a specific interface
interface HasId {
id: number;
}
function findById<T extends HasId>(items: T[], id: number): T | undefined {
return items.find(item => item.id === id);
}
See 1.25.h — Generic Interfaces and Constraints for a deep dive.
10. Real-world examples
Generic API fetch wrapper
async function apiFetch<T>(
url: string,
options?: RequestInit
): Promise<{ data: T; status: number }> {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data: T = await response.json();
return { data, status: response.status };
}
// Usage — fully typed responses:
interface User { id: number; name: string; email: string; }
interface Post { id: number; title: string; body: string; }
const { data: users } = await apiFetch<User[]>("/api/users");
// users is User[] — full autocomplete
const { data: post } = await apiFetch<Post>("/api/posts/1");
// post is Post — full autocomplete
Type-safe event emitter
type EventMap = Record<string, any>;
function createEmitter<Events extends EventMap>() {
const listeners: Partial<Record<keyof Events, Function[]>> = {};
return {
on<K extends keyof Events>(event: K, handler: (payload: Events[K]) => void): void {
(listeners[event] ??= []).push(handler);
},
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
listeners[event]?.forEach(fn => fn(payload));
},
};
}
// Define your event types:
interface AppEvents {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"notification": { message: string; level: "info" | "warn" | "error" };
}
const emitter = createEmitter<AppEvents>();
emitter.on("user:login", (payload) => {
// payload is { userId: string; timestamp: Date } — fully typed!
console.log(`User ${payload.userId} logged in at ${payload.timestamp}`);
});
emitter.emit("user:login", {
userId: "abc123",
timestamp: new Date(),
});
// Errors:
// emitter.emit("user:login", { userId: 123 }); // Error: number not assignable to string
// emitter.on("typo", () => {}); // Error: "typo" not in AppEvents
Key takeaways
- Generics use type parameters (
<T>) to create functions that work with any type while preserving type safety. - TypeScript infers generic type arguments — you rarely need to specify them explicitly.
- Multiple type parameters (
<T, U>) handle functions with different input/output types. - Generics shine with arrays (
T[]), objects (keyof T), and async operations (Promise<T>). - Built-in types like
Array<T>,Promise<T>,Record<K, V>, andMap<K, V>are all generic. - Use constraints (
T extends ...) to limit what types a generic accepts (detailed in 1.25.h).
Explain-It Challenge
Explain without notes:
- Why use
<T>instead ofanywhen writing a reusable function? - What does
function first<T>(arr: T[]): Tmean in plain English? - Give an example of TypeScript inferring a generic type argument.
Navigation: ← 1.25 Overview · 1.25.h — Generic Interfaces and Constraints →