From Zero to AI

Lesson 5.2: Generic Functions

Duration: 55 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  • Write generic functions with constraints
  • Use multiple type parameters effectively
  • Apply default type parameters
  • Understand generic type inference in depth
  • Create higher-order generic functions

Review: Basic Generic Functions

A quick refresher on generic function syntax:

function identity<T>(value: T): T {
  return value;
}

// Usage with inference
const num = identity(42); // T inferred as number
const str = identity('hello'); // T inferred as string

// Usage with explicit type
const arr = identity<number[]>([1, 2, 3]);

Generic Constraints

Sometimes you need to limit what types can be used with a generic. Use extends to add constraints:

Basic Constraints

// T must have a length property
function logLength<T extends { length: number }>(value: T): void {
  console.log(`Length: ${value.length}`);
}

logLength('hello'); // OK - strings have length
logLength([1, 2, 3]); // OK - arrays have length
logLength({ length: 5 }); // OK - object has length property

// Error: number has no length property
// logLength(42);

Constraining to Specific Types

// T must be a string or number
function formatValue<T extends string | number>(value: T): string {
  return `Value: ${value}`;
}

formatValue('hello'); // OK
formatValue(42); // OK

// Error: boolean is not string | number
// formatValue(true);

Constraining to Object Types

interface HasId {
  id: number;
}

function printId<T extends HasId>(item: T): void {
  console.log(`ID: ${item.id}`);
}

printId({ id: 1, name: 'Alice' }); // OK
printId({ id: 2, email: 'bob@example.com' }); // OK

// Error: missing id property
// printId({ name: "Charlie" });

The keyof Constraint

A common pattern is constraining one type parameter to be a key of another:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = {
  name: 'Alice',
  age: 30,
  email: 'alice@example.com',
};

const name = getProperty(person, 'name'); // string
const age = getProperty(person, 'age'); // number
const email = getProperty(person, 'email'); // string

// Error: "phone" is not a key of person
// getProperty(person, "phone");

Setting Multiple Properties

function setProperty<T, K extends keyof T>(obj: T, key: K, value: T[K]): void {
  obj[key] = value;
}

const user = { name: 'Bob', age: 25 };

setProperty(user, 'name', 'Robert'); // OK
setProperty(user, 'age', 26); // OK

// Error: value type does not match
// setProperty(user, "age", "twenty-six");

Multiple Type Parameters with Constraints

You can have multiple type parameters with their own constraints:

function merge<T extends object, U extends object>(obj1: T, obj2: U): T & U {
  return { ...obj1, ...obj2 };
}

const result = merge({ name: 'Alice' }, { age: 30 });
// Type: { name: string } & { age: number }

console.log(result.name); // "Alice"
console.log(result.age); // 30

Chaining Constraints

One type parameter can depend on another:

function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const users = [
  { name: 'Alice', age: 30 },
  { name: 'Bob', age: 25 },
  { name: 'Charlie', age: 35 },
];

const names = pluck(users, 'name'); // string[]
const ages = pluck(users, 'age'); // number[]

console.log(names); // ["Alice", "Bob", "Charlie"]
console.log(ages); // [30, 25, 35]

Default Type Parameters

Like default function parameters, type parameters can have defaults:

function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

// Uses default type (string)
const strings = createArray(3, 'hello'); // string[]

// Explicit type overrides default
const numbers = createArray<number>(3, 42); // number[]

Practical Example: Response Wrapper

interface ApiResponse<T = unknown> {
  data: T;
  status: number;
  message: string;
}

// Using default type
const response1: ApiResponse = {
  data: { any: 'thing' },
  status: 200,
  message: 'OK',
};

// With specific type
const response2: ApiResponse<{ id: number; name: string }> = {
  data: { id: 1, name: 'Product' },
  status: 200,
  message: 'OK',
};

Generic Arrow Functions

Arrow functions can also be generic:

// Regular arrow function
const identity = <T>(value: T): T => value;

// With constraint
const getLength = <T extends { length: number }>(value: T): number => {
  return value.length;
};

// Multiple type parameters
const pair = <T, U>(first: T, second: U): [T, U] => [first, second];

// Usage
const num = identity(42); // number
const len = getLength([1, 2, 3]); // 3
const p = pair('hello', 42); // [string, number]

Note on JSX

In JSX files (.tsx), the <T> syntax can be confused with JSX tags. Use one of these solutions:

// Solution 1: Add a constraint
const identity = <T extends unknown>(value: T): T => value;

// Solution 2: Add a trailing comma
const identity = <T,>(value: T): T => value;

// Solution 3: Use function keyword
function identity<T>(value: T): T {
  return value;
}

Higher-Order Generic Functions

Functions that return generic functions:

function createMapper<T>() {
  return function <U>(arr: T[], fn: (item: T) => U): U[] {
    return arr.map(fn);
  };
}

const numberMapper = createMapper<number>();
const doubled = numberMapper([1, 2, 3], (n) => n * 2); // number[]
const asStrings = numberMapper([1, 2, 3], (n) => String(n)); // string[]

Factory Functions

function createFactory<T>(creator: () => T) {
  return {
    create(): T {
      return creator();
    },
    createMany(count: number): T[] {
      return Array(count)
        .fill(null)
        .map(() => creator());
    },
  };
}

interface User {
  id: number;
  name: string;
}

let nextId = 1;
const userFactory = createFactory<User>(() => ({
  id: nextId++,
  name: `User ${nextId}`,
}));

const user = userFactory.create(); // User
const users = userFactory.createMany(3); // User[]

Generic Callbacks

Functions that accept generic callbacks:

function transform<T, R>(value: T, transformer: (input: T) => R): R {
  return transformer(value);
}

const length = transform('hello', (str) => str.length); // number
const upper = transform('hello', (str) => str.toUpperCase()); // string
const doubled = transform(21, (num) => num * 2); // number

Array Operations

function filterMap<T, R>(arr: T[], predicate: (item: T) => boolean, mapper: (item: T) => R): R[] {
  return arr.filter(predicate).map(mapper);
}

const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

const evenSquares = filterMap(
  numbers,
  (n) => n % 2 === 0,
  (n) => n * n
);
// [4, 16, 36, 64, 100]

Practical Examples

Example 1: Safe Object Access

function safeGet<T, K extends keyof T>(
  obj: T | null | undefined,
  key: K,
  defaultValue: T[K]
): T[K] {
  if (obj === null || obj === undefined) {
    return defaultValue;
  }
  return obj[key];
}

const user = { name: 'Alice', age: 30 };
const nullUser: typeof user | null = null;

console.log(safeGet(user, 'name', 'Unknown')); // "Alice"
console.log(safeGet(nullUser, 'name', 'Unknown')); // "Unknown"

Example 2: Group By

function groupBy<T, K extends keyof T>(arr: T[], key: K): Record<string, T[]> {
  return arr.reduce(
    (groups, item) => {
      const groupKey = String(item[key]);
      if (!groups[groupKey]) {
        groups[groupKey] = [];
      }
      groups[groupKey].push(item);
      return groups;
    },
    {} as Record<string, T[]>
  );
}

const people = [
  { name: 'Alice', department: 'Engineering' },
  { name: 'Bob', department: 'Marketing' },
  { name: 'Charlie', department: 'Engineering' },
  { name: 'Diana', department: 'Marketing' },
];

const byDepartment = groupBy(people, 'department');
/*
{
  Engineering: [{ name: "Alice", ... }, { name: "Charlie", ... }],
  Marketing: [{ name: "Bob", ... }, { name: "Diana", ... }]
}
*/

Example 3: Memoization

function memoize<T extends (...args: any[]) => any>(fn: T): T {
  const cache = new Map<string, ReturnType<T>>();

  return function (...args: Parameters<T>): ReturnType<T> {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      return cache.get(key)!;
    }

    const result = fn(...args);
    cache.set(key, result);
    return result;
  } as T;
}

function expensiveCalculation(n: number): number {
  console.log(`Calculating for ${n}...`);
  return n * n;
}

const memoized = memoize(expensiveCalculation);

console.log(memoized(5)); // Logs "Calculating for 5...", returns 25
console.log(memoized(5)); // Returns 25 (from cache, no log)
console.log(memoized(10)); // Logs "Calculating for 10...", returns 100

Example 4: Type-Safe Event Emitter

type EventHandler<T> = (data: T) => void;

function createEventEmitter<Events extends Record<string, any>>() {
  const handlers: Partial<{
    [K in keyof Events]: EventHandler<Events[K]>[];
  }> = {};

  return {
    on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>) {
      if (!handlers[event]) {
        handlers[event] = [];
      }
      handlers[event]!.push(handler);
    },

    emit<K extends keyof Events>(event: K, data: Events[K]) {
      handlers[event]?.forEach((handler) => handler(data));
    },
  };
}

// Define event types
interface MyEvents {
  login: { userId: number; timestamp: Date };
  logout: { userId: number };
  error: { message: string; code: number };
}

const emitter = createEventEmitter<MyEvents>();

emitter.on('login', (data) => {
  console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

emitter.on('error', (data) => {
  console.log(`Error ${data.code}: ${data.message}`);
});

emitter.emit('login', { userId: 1, timestamp: new Date() });
emitter.emit('error', { message: 'Not found', code: 404 });

Exercises

Exercise 1: Find Maximum

Create a generic function findMax that finds the maximum value in an array. Use a constraint to ensure the type has comparison operators:

// Test
console.log(findMax([3, 1, 4, 1, 5, 9])); // 9
console.log(findMax(['apple', 'zebra', 'mango'])); // "zebra"
console.log(findMax([new Date(2020, 0, 1), new Date(2023, 0, 1)])); // Date 2023
Solution
function findMax<T extends number | string | Date>(arr: T[]): T | undefined {
  if (arr.length === 0) return undefined;

  return arr.reduce((max, current) => {
    return current > max ? current : max;
  });
}

console.log(findMax([3, 1, 4, 1, 5, 9])); // 9
console.log(findMax(['apple', 'zebra', 'mango'])); // "zebra"
console.log(findMax([new Date(2020, 0, 1), new Date(2023, 0, 1)]));
// Date object for 2023

Exercise 2: Pick Properties

Create a generic function pick that creates a new object with only the specified properties:

// Test
const user = { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 };
const picked = pick(user, ['name', 'email']);
console.log(picked); // { name: "Alice", email: "alice@example.com" }
Solution
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 = { id: 1, name: 'Alice', email: 'alice@example.com', age: 30 };
const picked = pick(user, ['name', 'email']);
// Type: Pick<typeof user, "name" | "email">
console.log(picked); // { name: "Alice", email: "alice@example.com" }

Exercise 3: Debounce

Create a generic debounce function that delays function execution:

// Test
const log = debounce((message: string) => {
  console.log(message);
}, 1000);

log('Hello'); // Will log after 1 second if no more calls
log('World'); // Cancels previous, will log after 1 second
Solution
function debounce<T extends (...args: any[]) => void>(
  fn: T,
  delay: number
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout> | null = null;

  return function (...args: Parameters<T>) {
    if (timeoutId !== null) {
      clearTimeout(timeoutId);
    }

    timeoutId = setTimeout(() => {
      fn(...args);
      timeoutId = null;
    }, delay);
  };
}

const log = debounce((message: string) => {
  console.log(message);
}, 1000);

log('Hello');
log('World'); // Only "World" will be logged after 1 second

Exercise 4: Zip Arrays

Create a generic function zip that combines two arrays into an array of pairs:

// Test
const numbers = [1, 2, 3];
const letters = ['a', 'b', 'c'];
const zipped = zip(numbers, letters);
console.log(zipped); // [[1, "a"], [2, "b"], [3, "c"]]
Solution
function zip<T, U>(arr1: T[], arr2: U[]): [T, U][] {
  const length = Math.min(arr1.length, arr2.length);
  const result: [T, U][] = [];

  for (let i = 0; i < length; i++) {
    result.push([arr1[i], arr2[i]]);
  }

  return result;
}

const numbers = [1, 2, 3];
const letters = ['a', 'b', 'c'];
const zipped = zip(numbers, letters);
// Type: [number, string][]
console.log(zipped); // [[1, "a"], [2, "b"], [3, "c"]]

Key Takeaways

  1. Constraints limit types: Use extends to restrict what types can be used
  2. keyof enables property access: Constrain to valid keys of an object
  3. Default type parameters: Provide fallbacks like <T = string>
  4. Arrow functions support generics: Use <T> or <T,> syntax
  5. Higher-order functions: Generics work well with functions that return functions
  6. Type inference is powerful: Let TypeScript figure out types when possible

Resources

Resource Type Description
TypeScript Handbook: Generics Documentation Complete guide to generics
TypeScript Handbook: keyof Documentation Using keyof operator
TypeScript Deep Dive: Generics Tutorial Advanced generic patterns

Next Lesson

Now that you have mastered generic functions, let us apply these concepts to classes and interfaces.

Continue to Lesson 5.3: Generic Classes and Interfaces