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 with any.

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;
}
  • T is a convention, not a keyword. You can name it anything: <Type>, <Item>, <Element>.
  • T stands 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

  1. Generics use type parameters (<T>) to create functions that work with any type while preserving type safety.
  2. TypeScript infers generic type arguments — you rarely need to specify them explicitly.
  3. Multiple type parameters (<T, U>) handle functions with different input/output types.
  4. Generics shine with arrays (T[]), objects (keyof T), and async operations (Promise<T>).
  5. Built-in types like Array<T>, Promise<T>, Record<K, V>, and Map<K, V> are all generic.
  6. Use constraints (T extends ...) to limit what types a generic accepts (detailed in 1.25.h).

Explain-It Challenge

Explain without notes:

  1. Why use <T> instead of any when writing a reusable function?
  2. What does function first<T>(arr: T[]): T mean in plain English?
  3. Give an example of TypeScript inferring a generic type argument.

Navigation: ← 1.25 Overview · 1.25.h — Generic Interfaces and Constraints →