5.1 Generics Basics
What Are Generics?
When programming, you often encounter situations like "I want this function to handle both number arrays and string arrays." There are three options: first, write a separate function for each type; second, use any; third, use generics.
Generics allow you to pass types as parameters. Just as a function receives values as parameters, a generic function receives types as parameters. This lets a single function definition handle multiple types safely.
Regular function: function add(a: number, b: number): number
Generic function: function identity<T>(value: T): T
T is a type parameter. The actual type is determined at the time the function is called.
The Difference Between any and Generics
Using any erases all type information.
// Using any: no type safety
function identityAny(value: any): any {
return value;
}
const result1 = identityAny(42);
// result1's type is any — no autocompletion, no type checking
result1.toUpperCase(); // Error only discovered at runtime!
// Using generics: type safety preserved
function identity<T>(value: T): T {
return value;
}
const result2 = identity(42);
// result2's type is number — inferred by TypeScript
result2.toUpperCase(); // Compile-time error!
// Error: Property 'toUpperCase' does not exist on type 'number'
| any | Generics | |
|---|---|---|
| Type safety | None | Yes |
| Autocompletion | None | Yes |
| Type inference | None | Yes |
| Reusability | Yes | Yes |
Generic Function Syntax
Basic Syntax
Type parameters are declared inside angle brackets (<>).
// Basic generic function
function identity<T>(value: T): T {
return value;
}
// Arrow function with generics
const identityArrow = <T>(value: T): T => value;
// In .tsx files, add a trailing comma to avoid confusion with JSX
const identityTsx = <T,>(value: T): T => value;
Explicit Type Arguments vs. Type Inference
TypeScript infers type arguments automatically in most cases.
function identity<T>(value: T): T {
return value;
}
// Explicit type argument
const a = identity<string>("hello"); // T = string
const b = identity<number>(42); // T = number
// Inferred type argument
const c = identity("hello"); // T = string (inferred)
const d = identity(42); // T = number (inferred)
// When inference is not possible — must be explicit
function createArray<T>(length: number, defaultValue: T): T[] {
return Array.from({ length }, () => defaultValue);
}
const arr1 = createArray<string>(3, ""); // explicit
const arr2 = createArray(3, ""); // inferred (string from "")
const arr3 = createArray<number[]>(3, []); // explicit ([] alone is ambiguous)
Using the Same Type Parameter in Multiple Places
// Input and output types are linked
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
const first1 = firstElement([1, 2, 3]); // number | undefined
const first2 = firstElement(["a", "b", "c"]); // string | undefined
const first3 = firstElement([]); // unknown (empty array — T is unknown)
Generic Interfaces and Type Aliases
Generic Interfaces
// Defining a generic interface
interface Box<T> {
value: T;
label: string;
}
// Usage
const numberBox: Box<number> = { value: 42, label: "Number box" };
const stringBox: Box<string> = { value: "hello", label: "String box" };
// Generic interface with methods
interface Stack<T> {
push(item: T): void;
pop(): T | undefined;
peek(): T | undefined;
size(): number;
isEmpty(): boolean;
}
// Implementation
class ArrayStack<T> implements 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];
}
size(): number {
return this.items.length;
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numberStack = new ArrayStack<number>();
numberStack.push(1);
numberStack.push(2);
console.log(numberStack.peek()); // 2
console.log(numberStack.pop()); // 2
console.log(numberStack.size()); // 1
Generic Type Aliases
// Applying generics to type aliases
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type Maybe<T> = T | null | undefined;
// Also applicable to function types
type Transformer<T, U> = (input: T) => U;
type Predicate<T> = (value: T) => boolean;
type Comparator<T> = (a: T, b: T) => number;
// Usage examples
const toNumber: Transformer<string, number> = (s) => Number(s);
const isPositive: Predicate<number> = (n) => n > 0;
const compareNumbers: Comparator<number> = (a, b) => a - b;
// Recursive type alias (TypeScript 3.7+)
type Tree<T> = {
value: T;
left?: Tree<T>;
right?: Tree<T>;
};
const numberTree: Tree<number> = {
value: 1,
left: {
value: 2,
left: { value: 4 },
right: { value: 5 },
},
right: {
value: 3,
},
};
Multiple Type Parameters
Basic Multiple Parameters
// Generic function with two 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, ["a", "b"]); // [boolean, string[]]
const p3 = pair<string, number>("x", 1); // explicit
// Expressing relationships between types
function zipWith<T, U, R>(
arr1: T[],
arr2: U[],
fn: (a: T, b: U) => R
): R[] {
const length = Math.min(arr1.length, arr2.length);
const result: R[] = [];
for (let i = 0; i < length; i++) {
result.push(fn(arr1[i], arr2[i]));
}
return result;
}
const zipped = zipWith([1, 2, 3], ["a", "b", "c"], (n, s) => `${n}${s}`);
// ["1a", "2b", "3c"]
The swap Function
function swap<T, U>(pair: [T, U]): [U, T] {
return [pair[1], pair[0]];
}
const original: [string, number] = ["hello", 42];
const swapped = swap(original); // [number, string] = [42, "hello"]
console.log(original); // ["hello", 42]
console.log(swapped); // [42, "hello"]
Constraining Relationships Between Type Parameters
// Expressing the relationship between T and U via constraints
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ name: "Alice" }, { age: 30 });
// { name: string; age: number }
console.log(merged.name); // "Alice"
console.log(merged.age); // 30
Default Type Parameters
Since TypeScript 2.3, you can assign default values to type parameters.
// Default type parameter
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// Uses unknown when T is not specified
const rawResponse: ApiResponse = {
data: "raw",
status: 200,
message: "OK",
};
// Uses the specified type when T is provided
const typedResponse: ApiResponse<{ id: number; name: string }> = {
data: { id: 1, name: "Alice" },
status: 200,
message: "OK",
};
// Explicit type is still possible when a default exists
const stringResponse: ApiResponse<string> = {
data: "Hello",
status: 200,
message: "OK",
};
Constraints on Default Type Parameters
// A default type parameter can reference previous type parameters
interface Pair<T, U = T> {
first: T;
second: U;
}
const samePair: Pair<number> = { first: 1, second: 2 }; // U = number
const diffPair: Pair<number, string> = { first: 1, second: "a" }; // U = string
// Using constraints alongside default values
interface Container<T extends object = Record<string, unknown>> {
content: T;
metadata: { createdAt: Date };
}
const defaultContainer: Container = {
content: { key: "value" },
metadata: { createdAt: new Date() },
};
Generic Functions vs. Overloads
When Overloads Are Needed
// Overloads: when output type clearly differs based on input type
function processInput(input: string): string;
function processInput(input: number): number;
function processInput(input: string | number): string | number {
if (typeof input === "string") {
return input.toUpperCase();
}
return input * 2;
}
const s = processInput("hello"); // string
const n = processInput(42); // number
When Generics Are Appropriate
// Generics: when the same logic applies regardless of type
function identity<T>(value: T): T {
return value;
}
// Incorrect use of generics (overloads are more appropriate here)
// T gets inferred as string | number, which reduces precision
function processWrong<T extends string | number>(input: T): T {
if (typeof input === "string") {
return input.toUpperCase() as T; // forced cast — a warning sign
}
return (input as number * 2) as T; // same issue
}
Choosing Between Them
// When input and output have a consistent relationship — use generics
function wrap<T>(value: T): { value: T } {
return { value };
}
// When output type changes based on input type — use overloads
function toArray(value: string): string[];
function toArray(value: number): number[];
function toArray(value: string | number): string[] | number[] {
return [value as any];
}
Practical Examples
Generic ApiResponse Wrapper
// API response wrapper type
interface ApiResponse<T> {
data: T | null;
error: string | null;
status: number;
loading: boolean;
}
// Success/failure helper functions
function createSuccessResponse<T>(data: T, status = 200): ApiResponse<T> {
return {
data,
error: null,
status,
loading: false,
};
}
function createErrorResponse<T>(
error: string,
status = 500
): ApiResponse<T> {
return {
data: null,
error,
status,
loading: false,
};
}
function createLoadingResponse<T>(): ApiResponse<T> {
return {
data: null,
error: null,
status: 0,
loading: true,
};
}
// Usage
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
authorId: number;
}
const userResponse = createSuccessResponse<User>({
id: 1,
name: "Alice",
email: "alice@example.com",
});
const postResponse = createSuccessResponse<Post[]>([
{ id: 1, title: "First Post", content: "Content here", authorId: 1 },
{ id: 2, title: "Second Post", content: "More content", authorId: 1 },
]);
const errorResponse = createErrorResponse<User>("User not found.", 404);
// Type-safe response handling
function handleResponse<T>(
response: ApiResponse<T>,
onSuccess: (data: T) => void,
onError: (error: string) => void
): void {
if (response.loading) {
console.log("Loading...");
return;
}
if (response.error) {
onError(response.error);
return;
}
if (response.data !== null) {
onSuccess(response.data);
}
}
handleResponse(
userResponse,
(user) => console.log(`Welcome, ${user.name}!`),
(err) => console.error(`Error: ${err}`)
);
Generic Cache Implementation
class Cache<K, V> {
private store = new Map<K, { value: V; expiresAt: number }>();
private ttl: number;
constructor(ttlMs = 5 * 60 * 1000) { // default 5 minutes
this.ttl = ttlMs;
}
set(key: K, value: V): void {
this.store.set(key, {
value,
expiresAt: Date.now() + this.ttl,
});
}
get(key: K): V | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
has(key: K): boolean {
return this.get(key) !== undefined;
}
delete(key: K): boolean {
return this.store.delete(key);
}
clear(): void {
this.store.clear();
}
size(): number {
return this.store.size;
}
}
// Usage
const userCache = new Cache<number, User>(10 * 60 * 1000); // 10-minute TTL
userCache.set(1, { id: 1, name: "Alice", email: "alice@example.com" });
const cachedUser = userCache.get(1);
if (cachedUser) {
console.log(`Retrieved from cache: ${cachedUser.name}`);
}
Generic Pipeline
// A pipeline that transforms values
function pipe<T>(value: T): PipeBuilder<T> {
return new PipeBuilder(value);
}
class PipeBuilder<T> {
constructor(private value: T) {}
map<U>(fn: (value: T) => U): PipeBuilder<U> {
return new PipeBuilder(fn(this.value));
}
filter(predicate: (value: T) => boolean): PipeBuilder<T | undefined> {
if (predicate(this.value)) {
return new PipeBuilder<T | undefined>(this.value);
}
return new PipeBuilder<T | undefined>(undefined);
}
result(): T {
return this.value;
}
}
const result = pipe(42)
.map((n) => n * 2) // PipeBuilder<number>
.map((n) => n.toString()) // PipeBuilder<string>
.map((s) => s + "!") // PipeBuilder<string>
.result();
console.log(result); // "84!"
Pro Tips
Type Parameter Naming Conventions
These are the naming conventions commonly used in the TypeScript community.
// T — general Type
function identity<T>(value: T): T { return value; }
// K, V — Key and Value
interface KeyValuePair<K, V> {
key: K;
value: V;
}
// E — Element, typically for collection elements
interface Collection<E> {
add(element: E): void;
remove(element: E): boolean;
contains(element: E): boolean;
toArray(): E[];
}
// R — Return type
type Mapper<T, R> = (input: T) => R;
// P — Props (common in React components)
function withLogger<P extends object>(Component: React.FC<P>): React.FC<P> {
return (props: P) => {
console.log("Props:", props);
return Component(props);
};
}
// TError, TData — using T as a prefix for clarity
interface Result<TData, TError = Error> {
data: TData | null;
error: TError | null;
}
// For longer names, use meaningful abbreviations
// Item, Node, Value are also commonly used
type TreeNode<Item> = {
item: Item;
children: TreeNode<Item>[];
};
Avoid Overusing Generics
// Bad example — generics are not needed at all
function getLength<T extends { length: number }>(value: T): number {
return value.length;
}
// This is sufficient
function getLength(value: { length: number }): number {
return value.length;
}
// Bad example — T is only used once (same effect as any)
function log<T>(value: T): void {
console.log(value);
}
// This is sufficient
function log(value: unknown): void {
console.log(value);
}
// Good example — T plays a meaningful role linking input and output
function identity<T>(value: T): T {
return value;
}
// Good example — T ensures type consistency across multiple positions
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
return arr.map(fn);
}
Tips for Better Type Inference
// Improved tuple type inference
function tuple<T extends unknown[]>(...args: T): T {
return args;
}
const t = tuple(1, "hello", true); // [number, string, boolean]
// vs
const arr = [1, "hello", true]; // (number | string | boolean)[]
// Combining const assertion with generics
function createConfig<T extends Record<string, unknown>>(config: T): Readonly<T> {
return Object.freeze(config);
}
const config = createConfig({
host: "localhost",
port: 3000,
debug: true,
});
// config.host is string (readonly)
Summary Table
| Concept | Syntax | Description |
|---|---|---|
| Generic function | function f<T>(x: T): T | Function that takes a type as a parameter |
| Explicit type argument | f<string>("hello") | Directly specifying the type at call site |
| Type inference | f("hello") | Type automatically inferred from arguments |
| Generic interface | interface Box<T> | Interface with a type parameter |
| Generic type alias | type Pair<T, U> | Type alias with type parameters |
| Multiple parameters | <T, U> | Using multiple type parameters |
| Default type parameter | <T = string> | Default value when type argument is omitted |
| Naming convention | T, K, V, E, R | Single uppercase letters based on role |
What's Next...
Section 5.2 covers Generic Constraints. We'll look at how to use the extends keyword to require type parameters to satisfy certain conditions, patterns for safely accessing object properties using keyof, and the trade-off that overly strict constraints reduce reusability.