iBetter Books
수정

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 안에서 할 수 있는 것이 없고, 제약이 너무 강하면 재사용성이 떨어집니다. 필요한 속성만 최소한으로 제약하는 것이 좋은 제네릭 설계입니다.