From Zero to AI

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 parameter value has 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:

  1. 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();
}
  1. 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];
}
  1. 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:

  1. 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)}`;
}
  1. 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

  1. Generics solve code repetition: Write once, use with any type
  2. Type parameters are placeholders: <T> represents a type determined at usage
  3. Type inference works automatically: TypeScript figures out T from arguments
  4. Multiple type parameters are allowed: Use <T, U> for multiple types
  5. Generics preserve type safety: Unlike any, types are tracked throughout
  6. 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.

Continue to Lesson 5.2: Generic Functions