Lesson 5.1: Why Generics Are Needed
Duration: 45 minutes
Learning Objectives
By the end of this lesson, you will be able to:
- Understand the problem that generics solve
- Recognize situations where generics are useful
- Write basic generic syntax with type parameters
- Use generic type inference
The Problem: Repetitive Code
Imagine you need to create a function that returns the first element of an array. Without generics, you would need to write separate functions for each type:
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
function getFirstBoolean(arr: boolean[]): boolean | undefined {
return arr[0];
}
// Usage
const num = getFirstNumber([1, 2, 3]); // number | undefined
const str = getFirstString(['a', 'b', 'c']); // string | undefined
const bool = getFirstBoolean([true, false]); // boolean | undefined
This approach has problems:
- Repetition: Same logic written multiple times
- Maintenance: Changes must be made in multiple places
- Scalability: Need a new function for every type
The Wrong Solution: any
You might think to use any to handle all types:
function getFirst(arr: any[]): any {
return arr[0];
}
const num = getFirst([1, 2, 3]); // any - lost type information!
const str = getFirst(['a', 'b', 'c']); // any - lost type information!
// TypeScript cannot help us anymore
num.toUpperCase(); // No error, but crashes at runtime!
Using any throws away all type safety. TypeScript cannot tell you that calling toUpperCase() on a number is wrong.
The Solution: Generics
Generics let you write code that works with any type while preserving type information:
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const num = getFirst([1, 2, 3]); // number | undefined
const str = getFirst(['a', 'b', 'c']); // string | undefined
const bool = getFirst([true, false]); // boolean | undefined
// TypeScript knows the types!
num?.toFixed(2); // OK - num is number
str?.toUpperCase(); // OK - str is string
bool?.valueOf(); // OK - bool is boolean
// Errors are caught
num?.toUpperCase(); // Error! Property 'toUpperCase' does not exist on type 'number'
Generic Syntax Explained
The <T> is called a type parameter. Think of it as a placeholder for a type that will be determined later:
function identity<T>(value: T): T {
return value;
}
Breaking this down:
<T>- Declares a type parameter named T(value: T)- The parametervaluehas type T: T- The return type is also T
When you call the function, TypeScript figures out what T should be:
// TypeScript infers T = number
const a = identity(42); // Type: number
// TypeScript infers T = string
const b = identity('hello'); // Type: string
// TypeScript infers T = boolean
const c = identity(true); // Type: boolean
// TypeScript infers T = { name: string }
const d = identity({ name: 'Alice' }); // Type: { name: string }
Explicit Type Arguments
Sometimes you want to specify the type explicitly:
function identity<T>(value: T): T {
return value;
}
// Explicit type argument
const num = identity<number>(42);
const str = identity<string>('hello');
// Useful when inference is not possible
const empty = identity<string[]>([]); // Type: string[]
Without the explicit type, an empty array would be inferred as never[]:
const empty = identity([]); // Type: never[] - not useful!
Common Type Parameter Names
By convention, single uppercase letters are used:
| Name | Common Usage |
|---|---|
T |
Type (the most common) |
U, V |
Additional types |
K |
Key (in key-value pairs) |
V |
Value (in key-value pairs) |
E |
Element (in collections) |
R |
Return type |
You can use any name, but these conventions help other developers understand your code:
// Convention: T for type
function wrap<T>(value: T): { value: T } {
return { value };
}
// Convention: K for key, V for value
function createPair<K, V>(key: K, value: V): [K, V] {
return [key, value];
}
// Descriptive names work too
function transform<Input, Output>(value: Input, fn: (input: Input) => Output): Output {
return fn(value);
}
Multiple Type Parameters
Functions can have multiple type parameters:
function swap<T, U>(pair: [T, U]): [U, T] {
return [pair[1], pair[0]];
}
const result = swap([1, 'hello']); // Type: [string, number]
console.log(result); // ["hello", 1]
Another example with key-value pairs:
function createMap<K, V>(keys: K[], values: V[]): Map<K, V> {
const map = new Map<K, V>();
for (let i = 0; i < keys.length; i++) {
if (i < values.length) {
map.set(keys[i], values[i]);
}
}
return map;
}
const userMap = createMap([1, 2, 3], ['Alice', 'Bob', 'Charlie']);
// Type: Map<number, string>
Real-World Examples
Example 1: Array Wrapper
function wrapInArray<T>(value: T): T[] {
return [value];
}
const numbers = wrapInArray(42); // number[]
const strings = wrapInArray('hello'); // string[]
const objects = wrapInArray({ x: 1 }); // { x: number }[]
Example 2: Nullable Type
function makeNullable<T>(value: T): T | null {
return Math.random() > 0.5 ? value : null;
}
const maybeNumber = makeNullable(42); // number | null
const maybeString = makeNullable('hello'); // string | null
Example 3: Pair Creator
function makePair<T>(a: T, b: T): [T, T] {
return [a, b];
}
const numPair = makePair(1, 2); // [number, number]
const strPair = makePair('a', 'b'); // [string, string]
// Error: both must be same type
// makePair(1, "hello");
Example 4: Object Property Getter
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const person = { name: 'Alice', age: 30 };
const name = getProperty(person, 'name'); // string
const age = getProperty(person, 'age'); // number
// Error: "email" is not a key of person
// getProperty(person, "email");
When to Use Generics
Use generics when:
- A function works with multiple types: The same logic applies to different types
// Good use of generics
function reverse<T>(arr: T[]): T[] {
return [...arr].reverse();
}
- You need to preserve type information: The output type depends on input type
// Good use of generics
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
- You are building reusable data structures: Collections, containers, wrappers
// Good use of generics
class Box<T> {
constructor(private value: T) {}
getValue(): T {
return this.value;
}
}
When NOT to Use Generics
Avoid generics when:
- Only one type makes sense: Do not add complexity for no benefit
// Bad: generics not needed
function formatCurrency<T>(amount: T): string {
return `$${amount}`;
}
// Good: specific type is clearer
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
- You do not use the type parameter: If T appears only once, you might not need it
// Bad: T is only used once
function logValue<T>(value: T): void {
console.log(value);
}
// Good: any type works, no generic needed
function logValue(value: unknown): void {
console.log(value);
}
Exercises
Exercise 1: Last Element
Create a generic function getLast that returns the last element of an array:
// Test
console.log(getLast([1, 2, 3])); // Should be 3 (number)
console.log(getLast(['a', 'b', 'c'])); // Should be "c" (string)
console.log(getLast([])); // Should be undefined
Solution
function getLast<T>(arr: T[]): T | undefined {
return arr[arr.length - 1];
}
console.log(getLast([1, 2, 3])); // 3
console.log(getLast(['a', 'b', 'c'])); // "c"
console.log(getLast([])); // undefined
Exercise 2: Triple
Create a generic function makeTriple that takes three values of the same type and returns them as a tuple:
// Test
const nums = makeTriple(1, 2, 3); // Should be [1, 2, 3] with type [number, number, number]
const strs = makeTriple('a', 'b', 'c'); // Should be ["a", "b", "c"] with type [string, string, string]
Solution
function makeTriple<T>(a: T, b: T, c: T): [T, T, T] {
return [a, b, c];
}
const nums = makeTriple(1, 2, 3); // [number, number, number]
const strs = makeTriple('a', 'b', 'c'); // [string, string, string]
Exercise 3: Merge Objects
Create a generic function merge that takes two objects and returns a merged object:
// Test
const merged = merge({ name: 'Alice' }, { age: 30 });
console.log(merged); // { name: "Alice", age: 30 }
Solution
function merge<T, U>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = merge({ name: 'Alice' }, { age: 30 });
// Type: { name: string } & { age: number }
console.log(merged); // { name: "Alice", age: 30 }
console.log(merged.name); // "Alice"
console.log(merged.age); // 30
Exercise 4: Filter by Type
Create a generic function filterByType that filters an array to keep only elements of a certain type:
// Test
const mixed = [1, 'hello', 2, 'world', 3];
const numbers = filterByType(mixed, 'number'); // Should be [1, 2, 3]
const strings = filterByType(mixed, 'string'); // Should be ["hello", "world"]
Solution
function filterByType<T>(arr: unknown[], type: 'string' | 'number' | 'boolean'): T[] {
return arr.filter((item) => typeof item === type) as T[];
}
const mixed = [1, 'hello', 2, 'world', 3];
const numbers = filterByType<number>(mixed, 'number'); // [1, 2, 3]
const strings = filterByType<string>(mixed, 'string'); // ["hello", "world"]
Key Takeaways
- Generics solve code repetition: Write once, use with any type
- Type parameters are placeholders:
<T>represents a type determined at usage - Type inference works automatically: TypeScript figures out T from arguments
- Multiple type parameters are allowed: Use
<T, U>for multiple types - Generics preserve type safety: Unlike
any, types are tracked throughout - Use conventional names: T, U, K, V are standard type parameter names
Resources
| Resource | Type | Description |
|---|---|---|
| TypeScript Handbook: Generics | Documentation | Official guide to generics |
| TypeScript Deep Dive: Generics | Tutorial | In-depth explanation |
| TypeScript Playground | Tool | Try generics interactively |
Next Lesson
Now that you understand why generics exist, let us dive deeper into generic functions.