Ch 03. 제네릭 제약 조건
제네릭 T는 기본적으로 어떤 타입이든 받습니다. 그런데 함수 안에서 T에 특정 속성이 있다고 가정하거나, T가 특정 인터페이스를 구현했다고 가정하면 컴파일 오류가 납니다. 제약 조건(constraints)으로 T의 범위를 좁혀서 이 문제를 해결합니다.
extends로 제약 조건 적용하기
// 파일: src/generics/constraints.ts// T에 아무 제약이 없으면 length에 접근할 수 없음function getLength<T>(arg: T): number { return arg.length; // 오류: Property 'length' does not exist on type 'T'}// length 속성이 있는 타입으로 제약interface HasLength { length: number;}function getLength2<T extends HasLength>(arg: T): number { return arg.length; // 정상 — T는 length를 반드시 가짐}getLength2("hello"); // 5getLength2([1, 2, 3]); // 3getLength2({ length: 10 }); // 10// getLength2(42); // 오류 — number는 length가 없음
extends 뒤에 오는 타입이 제약 조건입니다. T extends HasLength는 "T는 HasLength를 만족하는 타입이어야 한다"는 뜻입니다.
keyof와 함께 사용하기
keyof는 객체 타입의 키를 유니온 타입으로 추출합니다. 제네릭과 함께 쓰면 안전한 프로퍼티 접근을 구현할 수 있습니다.
// 파일: src/generics/keyof-constraint.ts// T의 키 중 하나만 K로 받음function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] { return obj[key];}const user = { id: 1, name: "Alice", email: "[email protected]" };const name = getProperty(user, "name"); // stringconst id = getProperty(user, "id"); // number// getProperty(user, "phone"); // 오류 — phone은 user의 키가 아님// 여러 키 선택function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> { const result = {} as Pick<T, K>; keys.forEach((key) => { result[key] = obj[key]; }); return result;}const partial = pick(user, ["name", "email"]);// { name: string; email: string }
다중 제약 조건
여러 인터페이스를 동시에 만족해야 할 때는 인터섹션을 사용합니다.
// 파일: src/generics/multiple-constraints.tsinterface Printable { print(): void;}interface Serializable { serialize(): string;}// T가 Printable과 Serializable을 모두 구현해야 함function processItem<T extends Printable & Serializable>(item: T): string { item.print(); return item.serialize();}class Document implements Printable, Serializable { constructor( private title: string, private content: string ) {} print(): void { console.log(`[${this.title}] ${this.content}`); } serialize(): string { return JSON.stringify({ title: this.title, content: this.content }); }}const doc = new Document("TypeScript 가이드", "제네릭을 배워봅시다");console.log(processItem(doc));
기본 타입 매개변수 (Default Type Parameters)
함수의 기본 인자처럼 타입 매개변수에도 기본값을 줄 수 있습니다.
// 파일: src/generics/default-type-params.tsinterface ApiOptions<TData = unknown, TError = Error> { url: string; method: "GET" | "POST" | "PUT" | "DELETE"; onSuccess: (data: TData) => void; onError: (error: TError) => void;}// TData = unknown, TError = Error가 기본값function createApiCall<TData = unknown, TError = Error>( options: ApiOptions<TData, TError>) { return options;}// 기본값 사용 — TData, TError 생략 가능createApiCall({ url: "/api/data", method: "GET", onSuccess: (data) => console.log(data), // data: unknown onError: (err) => console.error(err), // err: Error});// 명시적으로 타입 지정interface User { id: number; name: string; }interface AppError { code: number; message: string; }createApiCall<User, AppError>({ url: "/api/users/1", method: "GET", onSuccess: (user) => console.log(user.name), // user: User onError: (err) => console.error(err.code), // err: AppError});
조건적 제약: 타입에 따라 다른 동작
// 파일: src/generics/conditional-constraint.ts// 비교 가능한 타입으로 제약 — number | string | Datetype Comparable = number | string | Date;function max<T extends Comparable>(a: T, b: T): T { if (a instanceof Date && b instanceof Date) { return a > b ? a : b; } return a > b ? a : b;}console.log(max(3, 7)); // 7console.log(max("apple", "banana")); // "banana"console.log(max(new Date("2024-01-01"), new Date("2025-01-01"))); // 2025년 날짜// 정렬 가능한 배열function sortArray<T extends Comparable>(arr: T[]): T[] { return [...arr].sort((a, b) => { if (a < b) return -1; if (a > b) return 1; return 0; });}console.log(sortArray([3, 1, 4, 1, 5])); // [1, 1, 3, 4, 5]console.log(sortArray(["banana", "apple", "cherry"])); // ["apple", "banana", "cherry"]
실전 예제: 제네릭 캐시
// 파일: src/generics/cache.tsinterface CacheEntry<T> { value: T; expiredAt: number;}class Cache<K extends string | number, V> { private store = new Map<K, CacheEntry<V>>(); set(key: K, value: V, ttlMs: number): void { this.store.set(key, { value, expiredAt: Date.now() + ttlMs, }); } get(key: K): V | undefined { const entry = this.store.get(key); if (!entry) return undefined; if (Date.now() > entry.expiredAt) { this.store.delete(key); return undefined; } return entry.value; } getOrSet(key: K, factory: () => V, ttlMs: number): V { const existing = this.get(key); if (existing !== undefined) return existing; const value = factory(); this.set(key, value, ttlMs); return value; } clear(): void { this.store.clear(); }}// 사용const cache = new Cache<string, { id: number; name: string }>();cache.set("user:1", { id: 1, name: "Alice" }, 60_000);const user = cache.get("user:1"); // { id: number; name: string } | undefined
제약 조건은 "이 타입 매개변수는 최소한 이런 구조를 가져야 한다"는 계약입니다. 제약이 없으면 T 안에서 할 수 있는 것이 없고, 제약이 너무 강하면 재사용성이 떨어집니다. 필요한 속성만 최소한으로 제약하는 것이 좋은 제네릭 설계입니다.