TypeScript Generics | Complete Guide

TypeScript Generics | Complete Guide

이 글의 핵심

A practical guide to TypeScript generics: replacing any with type parameters, functions and classes, constraints, keyof, and patterns like caches and async wrappers.

Introduction

What are generics?

Generics let you treat types like parameters so you can build reusable, type-safe components.


1. Generics basics

The problem

If a function must accept many types, using any throws away safety:

// any — loses type safety
function identity(value: any): any {
    return value;
}

const result1 = identity("hello");  // any
const result2 = identity(123);      // any

// Issues:
// 1. Return type is any — no checking
// 2. result1.toFixed() might compile but fail at runtime
// 3. Input/output relationship is not expressed

The generic solution

// <T> declares a type parameter (T is conventional)
function identity<T>(value: T): T {
    return value;
}

const result1 = identity<string>("hello");
const result2 = identity<number>(123);

// Inference (preferred)
const result3 = identity("hello");  // T inferred as string
const result4 = identity(123);      // T inferred as number

// Now the compiler preserves accuracy
// result3.toUpperCase();  // ✅
// result3.toFixed();      // ❌ error
// result4.toFixed(2);     // ✅

Benefits:

  1. Safety: checked at compile time
  2. Reuse: one implementation for many types
  3. Clarity: documents type relationships
  4. Tooling: better autocomplete

Generics vs any

AspectGenerics (<T>)any
Safety
Inference
Autocomplete
Runtime surprises✅ reduced❌ likely

2. Generic functions

Basics

function getFirstElement<T>(arr: T[]): T | undefined {
    return arr[0];
}

const numbers = [1, 2, 3];
const first = getFirstElement(numbers);

const strings = ["a", "b", "c"];
const firstStr = getFirstElement(strings);

Multiple type parameters

function pair<T, U>(first: T, second: U): [T, U] {
    return [first, second];
}

const result1 = pair("hello", 123);
const result2 = pair(true, "world");

Arrow functions

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

const numbers = [1, 2, 3];
const doubled = map(numbers, (n) => n * 2);
const strings = map(numbers, (n) => n.toString());

3. Generic interfaces

Basics

interface Box<T> {
    value: T;
}

const numberBox: Box<number> = { value: 123 };
const stringBox: Box<string> = { value: "hello" };

const boxOfBoxes: Box<Box<number>> = {
    value: { value: 123 }
};

Example: API responses

interface ApiResponse<T> {
    success: boolean;
    data: T;
    error?: string;
}

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

interface Product {
    id: string;
    name: string;
    price: number;
}

const userResponse: ApiResponse<User> = {
    success: true,
    data: {
        id: "U001",
        name: "Alice",
        email: "[email protected]"
    }
};

const productResponse: ApiResponse<Product[]> = {
    success: true,
    data: [
        { id: "P001", name: "Laptop", price: 1000000 },
        { id: "P002", name: "Mouse", price: 30000 }
    ]
};

4. Generic classes

Basics

Generic classes are ideal for reusable data structures:

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;
    }

    size(): number {
        return this.items.length;
    }
}

const numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop());
console.log(numberStack.peek());
// numberStack.push("hello");  // ❌ error

const stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");
console.log(stringStack.pop());
// stringStack.push(123);  // ❌ error

Why generic classes help:

  • One class for many element types
  • No duplicate StackNumber, StackString, etc.
  • Prevents mixing wrong types in the same stack
const taskStack = new Stack<Task>();
const undoStack = new Stack<Action>();
const historyStack = new Stack<string>();

5. Generic constraints

extends

interface Lengthwise {
    length: number;
}

function logLength<T extends Lengthwise>(value: T): void {
    console.log(value.length);
}

logLength("hello");        // ✅
logLength([1, 2, 3]);      // ✅
// logLength(123);         // ❌ number has no length

keyof

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

const user = {
    name: "Alice",
    age: 25,
    email: "[email protected]"
};

const name = getProperty(user, "name");
const age = getProperty(user, "age");
// const invalid = getProperty(user, "invalid");  // ❌ error

6. Hands-on examples

Example 1: Chunk arrays

function chunk<T>(arr: T[], size: number): T[][] {
    const result: T[][] = [];
    for (let i = 0; i < arr.length; i += size) {
        result.push(arr.slice(i, i + size));
    }
    return result;
}

const numbers = [1, 2, 3, 4, 5, 6];
console.log(chunk(numbers, 2));

const strings = ["a", "b", "c", "d"];
console.log(chunk(strings, 3));

Example 2: Key–value cache

class Cache<K, V> {
    private store = new Map<K, V>();

    set(key: K, value: V): void {
        this.store.set(key, value);
    }

    get(key: K): V | undefined {
        return this.store.get(key);
    }

    has(key: K): boolean {
        return this.store.has(key);
    }

    delete(key: K): boolean {
        return this.store.delete(key);
    }

    clear(): void {
        this.store.clear();
    }
}

const userCache = new Cache<string, User>();
userCache.set("U001", { id: "U001", name: "Alice", email: "[email protected]" });

const user = userCache.get("U001");
console.log(user?.name);

Example 3: Promise wrapper

class AsyncResult<T> {
    constructor(private promise: Promise<T>) {}

    async map<U>(fn: (value: T) => U): Promise<AsyncResult<U>> {
        const value = await this.promise;
        return new AsyncResult(Promise.resolve(fn(value)));
    }

    async flatMap<U>(fn: (value: T) => Promise<U>): Promise<AsyncResult<U>> {
        const value = await this.promise;
        return new AsyncResult(fn(value));
    }

    async unwrap(): Promise<T> {
        return await this.promise;
    }
}

const result = new AsyncResult(Promise.resolve(10));

result
    .map((x) => x * 2)
    .then((r) => r.unwrap())
    .then((value) => console.log(value));  // 20

7. Advanced patterns

Conditional types

type IsString<T> = T extends string ? true : false;

type A = IsString<string>;   // true
type B = IsString<number>;   // false

Mapped types (preview)

type Readonly<T> = {
    readonly [K in keyof T]: T[K];
};

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

type ReadonlyUser = Readonly<User>;
// { readonly name: string; readonly age: number; }

8. Common mistakes

Mistake 1: Accessing properties without a constraint

// ❌ Wrong
function getLength<T>(value: T): number {
    return value.length;  // T might not have length
}

// ✅ Correct
function getLength<T extends { length: number }>(value: T): number {
    return value.length;
}

Mistake 2: Unnecessary generics

// ❌ Generic adds nothing
function log<T>(message: string): void {
    console.log(message);
}

// ✅ Simpler
function log(message: string): void {
    console.log(message);
}

Summary

Takeaways

  1. Generics: parameterize types
  2. Functions: function fn<T>(value: T): T
  3. Interfaces: interface Box<T>
  4. Classes: class Stack<T>
  5. Constraints: <T extends SomeType>
  6. keyof: keys of object types

Next steps

  • TypeScript utility types
  • TypeScript decorators
  • Advanced TypeScript patterns