Chapter 6: Generics (or: How I Learned to Stop Worrying and Love )
Generics look intimidating. Those angle brackets, the single-letter type names, the academic terminology. But strip away the syntax, and generics are just parameterized types—types that take arguments, like functions take arguments.
If you understand functions with parameters, you understand generics.
The Problem Generics Solve
Without generics, you have two bad options:
Option 1: Type-specific functions
function identityString(value: string): string {
return value;
}
function identityNumber(value: number): number {
return value;
}
function identityBoolean(value: boolean): boolean {
return value;
}
Repetitive. Doesn’t scale.
Option 2: any
function identity(value: any): any {
return value;
}
const result = identity(42); // Type: any
// Lost all type information
Works, but you’ve thrown away the type system.
Generics: The solution
function identity<T>(value: T): T {
return value;
}
const num = identity(42); // Type: number
const str = identity('hello'); // Type: string
const bool = identity(true); // Type: boolean
One function. Full type safety. The <T>
is a type parameter—a placeholder for whatever type you pass in.
Generic Functions
The syntax: <TypeParameter>
before the parameter list.
function wrap<T>(value: T): { value: T } {
return { value };
}
const wrapped = wrap(42);
// Type: { value: number }
console.log(wrapped.value); // 42
TypeScript infers T
from the argument. You don’t need to specify it explicitly:
wrap<number>(42); // Explicit (unnecessary here)
wrap(42); // Inferred (better)
Multiple Type Parameters
function pair<T, U>(first: T, second: U): [T, U] {
return [first, second];
}
const p1 = pair(1, 'hello');
// Type: [number, string]
const p2 = pair('foo', true);
// Type: [string, boolean]
Convention uses T
, U
, V
, etc. But you can use descriptive names:
function pair<First, Second>(first: First, second: Second): [First, Second] {
return [first, second];
}
Single letters are convention, not law.
Generic Constraints
Sometimes you need to restrict what T
can be:
function getLength<T>(value: T): number {
return value.length; // Error: Property 'length' does not exist on type 'T'
}
T
could be anything. Not everything has .length
.
Solution: Constrain T
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(42); // Error: Argument of type 'number' is not assignable to parameter of type '{ length: number; }'
T extends { length: number }
means “T
must have a length
property that’s a number.”
You can constrain to specific types:
function logValue<T extends string | number>(value: T): void {
console.log(value);
}
logValue('hello'); // OK
logValue(42); // OK
logValue(true); // Error
Using Type Parameters in Constraints
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30 };
const name = getProperty(user, 'name'); // Type: string
const age = getProperty(user, 'age'); // Type: number
getProperty(user, 'email'); // Error: Argument of type '"email"' is not assignable to parameter of type '"name" | "age"'
K extends keyof T
means “K
must be a key of T
.”
T[K]
means “the type of property K
on T
.”
This is type-safe property access. No runtime errors. The compiler prevents invalid keys.
Generic Interfaces
interface Box<T> {
value: T;
}
const numBox: Box<number> = { value: 42 };
const strBox: Box<string> = { value: 'hello' };
You must specify the type parameter when using a generic interface (unlike functions, where it’s inferred).
API Response Example
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
interface User {
id: string;
name: string;
}
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return { success: true, data };
}
const result = await fetchUser('123');
if (result.success && result.data) {
console.log(result.data.name); // TypeScript knows result.data is User
}
One ApiResponse
type. Works with any data type.
Generic Type Aliases
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return { ok: false, error: 'Division by zero' };
}
return { ok: true, value: a / b };
}
const result = divide(10, 2);
if (result.ok) {
console.log(result.value); // Type: number
} else {
console.log(result.error); // Type: string
}
Notice E = Error
. That’s a default type parameter. If you don’t specify E
, it defaults to Error
.
type Result<T> = Result<T, Error>; // Simplified version
Generic Classes
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];
}
isEmpty(): boolean {
return this.items.length === 0;
}
}
const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
console.log(numStack.pop()); // 2
const strStack = new Stack<string>();
strStack.push('hello');
strStack.push('world');
console.log(strStack.pop()); // 'world'
One class. Multiple types. Type-safe operations.
Arrays Are Generic
You’ve been using generics all along:
const numbers: Array<number> = [1, 2, 3];
// Equivalent to: number[]
const strings: Array<string> = ['a', 'b', 'c'];
// Equivalent to: string[]
Array<T>
is a built-in generic. T[]
is syntactic sugar.
Promises Are Generic
async function fetchData(): Promise<string> {
const response = await fetch('/api/data');
return response.text();
}
const data = await fetchData(); // Type: string
Promise<T>
wraps a value of type T
.
Generic Utility Types (Revisited)
TypeScript’s built-in utility types are all generic:
type Partial<T> = {
[P in keyof T]?: T[P];
};
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
type Record<K extends string | number | symbol, T> = {
[P in K]: T;
};
These are mapped types (Chapter 7). For now, understand they’re generic—they take a type and transform it.
Practical Patterns
Repository Pattern
interface Repository<T> {
find(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<void>;
}
interface User {
id: string;
name: string;
email: string;
}
class UserRepository implements Repository<User> {
async find(id: string): Promise<User | null> {
// Implementation
return null;
}
async findAll(): Promise<User[]> {
// Implementation
return [];
}
async save(user: User): Promise<User> {
// Implementation
return user;
}
async delete(id: string): Promise<void> {
// Implementation
}
}
One interface. Works for any entity type.
State Management
interface State<T> {
value: T;
update(newValue: T): void;
subscribe(listener: (value: T) => void): () => void;
}
function createState<T>(initialValue: T): State<T> {
let value = initialValue;
const listeners: Array<(value: T) => void> = [];
return {
get value() {
return value;
},
update(newValue: T) {
value = newValue;
listeners.forEach(listener => listener(value));
},
subscribe(listener: (value: T) => void) {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
}
};
}
const counter = createState(0);
counter.subscribe(value => console.log('Counter:', value));
counter.update(1); // Logs: Counter: 1
Option/Maybe Type
type Option<T> = Some<T> | None;
interface Some<T> {
kind: 'some';
value: T;
}
interface None {
kind: 'none';
}
function some<T>(value: T): Option<T> {
return { kind: 'some', value };
}
function none(): Option<never> {
return { kind: 'none' };
}
function unwrap<T>(option: Option<T>, defaultValue: T): T {
return option.kind === 'some' ? option.value : defaultValue;
}
const maybeUser = some({ name: 'Alice' });
const user = unwrap(maybeUser, { name: 'Guest' });
console.log(user.name); // Alice
Generic Constraints in Practice
Ensuring Properties Exist
interface HasId {
id: string;
}
function findById<T extends HasId>(items: T[], id: string): T | undefined {
return items.find(item => item.id === id);
}
const users = [
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' }
];
const user = findById(users, '1');
// Type: { id: string; name: string } | undefined
Constructor Constraints
interface Constructable<T> {
new (...args: any[]): T;
}
function create<T>(ctor: Constructable<T>, ...args: any[]): T {
return new ctor(...args);
}
class User {
constructor(public name: string, public age: number) {}
}
const user = create(User, 'Alice', 30);
// Type: User
Extending Built-in Types
function first<T extends any[]>(arr: T): T[0] | undefined {
return arr[0];
}
const nums = [1, 2, 3];
const firstNum = first(nums); // Type: number | undefined
const strs = ['a', 'b', 'c'];
const firstStr = first(strs); // Type: string | undefined
Variance (Advanced but Important)
Generics have variance—rules about when one generic type can substitute for another.
Covariance (Arrays are covariant)
class Animal {
name: string = 'animal';
}
class Dog extends Animal {
bark() {
console.log('Woof!');
}
}
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // OK (covariant)
// But this can cause problems:
animals.push(new Animal()); // Allowed, but now dogs array has a non-Dog
dogs[1].bark(); // Runtime error: bark doesn't exist on Animal
TypeScript allows this (for compatibility with JavaScript), but it’s technically unsound.
Invariance (Ideally, this should be enforced)
Properly, generic types should be invariant—T[]
is not assignable to U[]
unless T
is exactly U
.
In practice, TypeScript is pragmatic. Arrays are covariant. Functions are contravariant in parameters, covariant in return types.
Takeaway: Be careful when mixing subclasses and generics. TypeScript won’t always catch mistakes.
Generic Default Parameters
interface ApiResponse<T = unknown> {
data: T;
}
const response1: ApiResponse<User> = { data: { id: '1', name: 'Alice' } };
const response2: ApiResponse = { data: 'anything' }; // T defaults to unknown
Defaults are useful for library APIs where the user might not always specify a type.
Conditional Types (Preview)
Generics can have conditional logic:
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
We’ll cover this in Chapter 7, but know that generics can be much more powerful than simple placeholders.
When NOT to Use Generics
Over-Generalization
// Bad: unnecessary generic
function add<T>(a: T, b: T): T {
return (a as any) + (b as any);
}
// Good: just use the right type
function add(a: number, b: number): number {
return a + b;
}
If you’re using any
inside a generic, you probably don’t need the generic.
Premature Abstraction
Don’t add generics “just in case.” Add them when you have multiple concrete use cases.
// Bad: speculative generic
interface Repository<T, K> {
find(key: K): Promise<T | null>;
}
// Good: start specific, generalize later
interface UserRepository {
find(id: string): Promise<User | null>;
}
Start concrete. Abstract when patterns emerge.
Readability
// Hard to read
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
// Sometimes simpler is better
type UpdateUser = {
name?: string;
email?: string;
age?: number;
};
Generics add cognitive load. Use them when the benefit (reusability, type safety) outweighs the cost (complexity).
Common Pitfalls
Forgetting Constraints
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
merge('hello', 'world'); // Compiles, but runtime error (strings aren't spreadable)
Better:
function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
merge('hello', 'world'); // Error: Argument of type 'string' is not assignable to parameter of type 'object'
Overusing any
in Generic Implementations
// Bad: defeats the purpose
function identity<T>(value: T): T {
const temp: any = value;
return temp;
}
// Good: just use T
function identity<T>(value: T): T {
return value;
}
If your generic implementation uses any
, rethink the design.
Type Parameter Shadowing
class Container<T> {
value: T;
constructor(value: T) {
this.value = value;
}
// Bad: shadows class T
map<T>(fn: (value: T) => T): Container<T> {
return new Container(fn(this.value as any));
}
}
The method’s T
shadows the class’s T
. Use a different name:
class Container<T> {
value: T;
constructor(value: T) {
this.value = value;
}
map<U>(fn: (value: T) => U): Container<U> {
return new Container(fn(this.value));
}
}
What You’ve Learned
- Generics are parameterized types—types that take arguments
<T>
is a type parameter, like function parameters but for types- Constraints (
T extends U
) restrict what types can be used - Type inference usually figures out generic parameters automatically
- Generic interfaces, classes, and functions enable reusable, type-safe code
- Variance (covariance, contravariance) affects how generic types relate
- Don’t over-generic—start specific, generalize when needed
Generics are TypeScript’s superpower for abstraction. They let you write code once and reuse it with full type safety across many types.
The syntax is terse. The concepts are simple. The applications are endless.
Next: Chapter 7: Advanced Types and the Compiler’s Bag of Tricks