타입스크립트 기초 강좌 #6 제네릭 깊이

16 분 소요

지난 시간에는 함수 타입을 다루며 제네릭과 첫 만남을 가졌습니다. 이번에는 그 위에서 한 걸음 더 들어가서 제네릭의 진짜 표현력을 끌어내는 도구들 — 제약, 다중 타입 매개변수, 제네릭 인터페이스/클래스, keyof, 인덱스 액세스 타입을 정리합니다.

이번 글의 내용은 처음엔 어렵게 느껴질 수 있습니다. 모든 패턴을 한 번에 외울 필요는 없고, **"이런 도구가 있다"**는 감각만 잡으셔도 충분해요.

제약 복습 — extends

#5에서 잠깐 봤던 제약. 제네릭 타입에 조건을 거는 도구입니다.

제약 — length가 있는 타입만
function logLength<T extends { length: number }>(value: T): void {
  console.log(value.length);
}
 
logLength('hello');       // ✓
logLength([1, 2, 3]);     // ✓
logLength(42);            // 🚫

T extends 모양은 "T는 그 모양과 호환되는 어떤 타입"이라는 뜻이에요. 함수 본문에서 그 모양에 의존하는 코드를 안전하게 작성할 수 있게 됩니다.

다중 타입 매개변수

여러 타입 변수가 함께 쓰일 때, 그들 사이에 관계를 표현할 수 있습니다.

key를 안전하게 꺼내기
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
 
const user = { id: 'u-1', name: '철수', age: 30 };
 
const id = getProperty(user, 'id');       // string
const age = getProperty(user, 'age');     // number
const bad = getProperty(user, 'foo');     // 🚫 'foo'는 user에 없음

핵심 두 가지가 등장했어요.

keyof T

keyof TT의 모든 키 이름의 union입니다.

type User = { id: string; name: string; age: number };
 
type UserKeys = keyof User;   // 'id' | 'name' | 'age'

literal 타입 union(#4)이 자동 생성되는 셈이에요. 객체의 키들을 타입 수준에서 다룰 수 있게 해주는 핵심 도구입니다.

인덱스 액세스 타입 — T[K]

T[K]는 "T 타입의 K 키에 해당하는 값의 타입"입니다.

type User = { id: string; name: string; age: number };
 
type IdType = User['id'];       // string
type NameType = User['name'];   // string
type AgeType = User['age'];     // number

객체 접근 문법(user.id, user['id'])을 타입 수준에서도 그대로 쓸 수 있는 거예요.

위의 getProperty<T, K extends keyof T>에서 일어나는 일:

  1. 호출자가 getProperty(user, 'id')로 호출
  2. Tuser의 타입(User)으로 추론됨
  3. K'id'로 추론됨
  4. 반환 타입 T[K]User['id'] = string

타입 변수들이 서로 연결되면서 getProperty(user, 'id')의 반환 타입이 정확히 string이 됩니다. 잘못된 키('foo')를 넘기면 K extends keyof T 제약을 위반해 컴파일 에러.

이게 자바스크립트로는 표현할 수 없는 타입스크립트의 진짜 표현력이에요.

제네릭 인터페이스와 type alias

함수만이 아니라 타입 별명 자체도 제네릭으로 만들 수 있습니다.

제네릭 type alias
type Box<T> = {
  value: T;
};
 
const a: Box<number> = { value: 42 };
const b: Box<string> = { value: 'hello' };

Box는 어떤 타입이든 담을 수 있는 일반 컨테이너입니다. 사용 시점에 Box<number>처럼 타입을 채워서 구체화하죠.

interface도 동일:

제네릭 interface
interface ApiResponse<T> {
  data: T;
  status: number;
  timestamp: number;
}
 
const userResp: ApiResponse<User> = {
  data: { id: 'u-1', name: '철수' },
  status: 200,
  timestamp: Date.now(),
};

이런 패턴은 API 응답 래퍼, 결과 컨테이너 등에서 매우 자주 쓰입니다.

Discriminated union을 제네릭으로

#4에서 본 비동기 상태 패턴을 제네릭으로 일반화:

제네릭 결과 타입
type Result<T, E = string> =
  | { ok: true; value: T }
  | { ok: false; error: E };
 
function divide(a: number, b: number): Result<number> {
  if (b === 0) return { ok: false, error: '0으로 나눌 수 없음' };
  return { ok: true, value: a / b };
}
 
const r = divide(10, 2);
if (r.ok) {
  console.log(r.value);   // r.value: number
} else {
  console.log(r.error);   // r.error: string
}

Result<T, E>T(성공 값 타입)와 E(에러 타입, 기본값 string) 두 매개변수를 받습니다. 사용처에서 적절한 타입을 채워 구체화.

이 패턴은 Rust 같은 언어에서 영감을 받은 타입 안전한 에러 처리 방식입니다. throw 대신 결과 객체를 반환하는 거예요. 큰 코드베이스에서 인기 있는 스타일입니다.

제네릭 클래스

클래스도 제네릭으로 만들 수 있습니다.

제네릭 Stack
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];
  }
 
  get size(): number {
    return this.items.length;
  }
}
 
const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
const top = numStack.pop();   // top: number | undefined
 
const strStack = new Stack<string>();
strStack.push('hello');

같은 Stack 클래스가 number, string, 어떤 타입이든 담을 수 있습니다. T는 인스턴스 생성 시점에 정해지죠.

자료구조(Stack, Queue, LinkedList) 구현이 제네릭 클래스의 가장 자연스러운 사용처입니다.

조건부 타입 — 살짝 맛보기

타입 자체에서 조건문을 쓸 수 있다는 게 신기한 기능 중 하나입니다.

conditional types
type IsString<T> = T extends string ? true : false;
 
type A = IsString<'hello'>;   // true
type B = IsString<42>;         // false

T extends string ? true : false는 "T가 string의 부분집합이면 true 타입, 아니면 false 타입"이라는 의미예요.

실용적인 예시:

배열 요소 타입 추출
type ArrayElement<T> = T extends (infer U)[] ? U : never;
 
type A = ArrayElement<number[]>;       // number
type B = ArrayElement<string[]>;       // string
type C = ArrayElement<{ x: 1 }[]>;     // { x: 1 }

infer U는 "타입 패턴 매칭 결과를 U라는 이름으로 받음"이라는 의미. T가 어떤 배열 타입(U[])이라면 그 요소 타입을 U로 추출하는 거예요.

조건부 타입과 infer는 타입스크립트의 메타 프로그래밍 영역입니다. 라이브러리 작성자나 타입 유틸리티를 직접 만드는 사람들이 주로 사용하고, 일상적으로 자주 쓸 일은 적지만 알아두면 강력한 도구예요.

매핑된 타입 (mapped types)

기존 타입의 각 프로퍼티를 변환해 새 타입을 만드는 기법입니다.

매핑된 타입
type ReadonlyUser = {
  readonly [K in keyof User]: User[K];
};

[K in keyof User]는 "User의 각 키 K에 대해" 반복하는 의미예요. 그러면서 각 프로퍼티에 readonly를 붙입니다. 결과적으로 User의 모든 프로퍼티가 readonly가 된 타입.

타입스크립트가 이런 패턴을 자주 사용한다는 걸 알아서, 자주 쓰는 변환들을 유틸리티 타입으로 미리 제공합니다.

유틸리티 타입 미리 보기
type ReadonlyUser = Readonly<User>;
type PartialUser = Partial<User>;     // 모든 필드를 옵셔널로
type RequiredUser = Required<User>;   // 모든 옵셔널을 필수로

Readonly<T>, Partial<T> 같은 게 매핑된 타입으로 정의되어 있는 거예요. 각각 어떻게 정의되는지는 #7에서 다룹니다.

실전 패턴 모음

1. 객체의 값들의 union 타입

값 타입 union
const ROLES = {
  ADMIN: 'admin',
  EDITOR: 'editor',
  VIEWER: 'viewer',
} as const;
 
type Role = typeof ROLES[keyof typeof ROLES];
// 'admin' | 'editor' | 'viewer'

typeof ROLES는 ROLES 객체의 타입, keyof typeof ROLES는 그 키들('ADMIN' | 'EDITOR' | 'VIEWER'), 인덱스 액세스로 값들을 union으로 만든 결과.

데이터 한 곳에 두고 타입을 거기서 자동 유도하는 매우 강력한 패턴입니다. 데이터-타입 동기화 부담 0.

2. 함수의 반환 타입 추출

ReturnType 활용
function fetchUser() {
  return { id: 'u-1', name: '철수', email: 'cheolsu@example.com' };
}
 
type User = ReturnType<typeof fetchUser>;
// { id: string; name: string; email: string }

함수가 반환하는 객체의 모양을 타입으로 추출. 별도로 타입을 정의하지 않고도 함수 시그니처에서 자동으로 가져옵니다.

3. 안전한 객체 키 변환

객체 키 변환
function transform<T extends Record<string, any>, U>(
  obj: T,
  fn: <K extends keyof T>(value: T[K], key: K) => U
): Record<keyof T, U> {
  const result = {} as Record<keyof T, U>;
  for (const key in obj) {
    result[key] = fn(obj[key], key);
  }
  return result;
}

위 코드는 일반화된 객체 변환 함수입니다. 타입이 좀 복잡하지만, 제네릭 + 제약 + keyof + 인덱스 액세스 + Record가 모두 등장하는 좋은 종합 예시예요. 처음엔 모든 부분을 이해 못 해도 괜찮습니다.

흔한 함정

1. 제네릭이 너무 많음

🚫 과도한 제네릭
function add<A extends number, B extends number>(a: A, b: B): number {
  return a + b;
}

AB가 number의 부분집합인 게 의미가 없습니다. 그냥 function add(a: number, b: number)로 충분해요. 제네릭은 다양한 타입에 일반화가 필요할 때만 쓰세요.

2. extends 쓰임의 두 가지 의미

type IsString<T> = T extends string ? true : false;   // 조건부 타입
function log<T extends string>(value: T): void {}     // 제약

같은 extends인데 두 자리에서 의미가 다릅니다.

  • 제네릭 매개변수 옆 (<T extends ...>): 제약
  • 조건부 타입 (T extends ... ? ... : ...): 조건 검사

문법 위치로 구분하면 됩니다.

3. any와 unknown 헷갈리기

제네릭에서 모르는 타입을 받을 때 어느 쪽?

  • any — 검사 끔. 위험. 가능하면 피해야 함
  • unknown — 검사 강제. 안전. narrowing 필요
  • T (제네릭) — 호출자가 타입 결정. 함수가 그 타입을 보존해줌

데이터를 그대로 통과시키는 함수라면 거의 항상 제네릭이 정답입니다. anyunknown을 쓰면 호출자가 타입 정보를 잃어요.

비교
function bad(value: any): any { return value; }
function good<T>(value: T): T { return value; }
 
const a = bad(42);    // a: any (타입 정보 사라짐)
const b = good(42);   // b: number (타입 정보 보존)

직접 해보기 — 미니 EventEmitter

제네릭의 표현력을 보여주는 작은 예시. 이벤트 이름과 페이로드 타입을 매핑한 EventEmitter입니다.

event-emitter.ts
type EventMap = {
  click: { x: number; y: number };
  hover: { id: string };
  submit: { values: Record<string, string> };
};
 
type Listener<T> = (payload: T) => void;
 
class TypedEmitter<TEvents extends Record<string, any>> {
  private listeners: { [K in keyof TEvents]?: Listener<TEvents[K]>[] } = {};
 
  on<K extends keyof TEvents>(event: K, listener: Listener<TEvents[K]>): void {
    if (!this.listeners[event]) this.listeners[event] = [];
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): void {
    this.listeners[event]?.forEach(l => l(payload));
  }
}
 
const emitter = new TypedEmitter<EventMap>();
 
emitter.on('click', payload => {
  console.log(payload.x, payload.y);   // payload는 자동으로 { x, y } 타입
});
 
emitter.on('hover', payload => {
  console.log(payload.id);             // payload는 { id } 타입
});
 
emitter.emit('click', { x: 10, y: 20 });   // ✓
emitter.emit('click', { id: 'u-1' });       // 🚫 타입 안 맞음
emitter.emit('xxxxx', {});                  // 🚫 등록 안 된 이벤트

이벤트 이름과 그에 맞는 페이로드 타입이 EventMap에 한 번 정의되면, onemit 호출이 모두 그 정의를 따라 자동으로 검사됩니다. 잘못된 페이로드를 emit하면 컴파일 에러.

자바스크립트로는 코드 + 주석으로 표현해야 했던 것이 타입으로 강제됩니다. 이런 게 타입스크립트의 매력이에요.

마무리

이번 글에서는 제네릭의 깊은 도구들을 다뤘습니다.

  • 제약 (extends) — 타입 변수에 조건 걸기
  • 다중 타입 매개변수 — 변수들 사이의 관계 표현
  • keyof T — 객체 키들의 union 타입
  • 인덱스 액세스 T[K] — 객체의 특정 키의 값 타입
  • 제네릭 type alias / interface / class — 타입 모양에 변수 끼우기
  • conditional types와 infer — 타입 수준 패턴 매칭 (살짝 맛보기)
  • 매핑된 타입 — 객체의 각 프로퍼티 변환

여기까지 오면 타입스크립트의 거의 모든 표현력을 손에 잡은 셈이에요. 마지막 한 글이 남았습니다. "타입스크립트 기초 강좌 #7 유틸리티 타입과 tsconfig"에서는 이번 글에서 살짝 짚은 Partial, Pick, Omit, ReturnType 같은 표준 유틸리티 타입들을 한 번에 정리하고, 컴파일 동작을 결정하는 **tsconfig.json**의 핵심 옵션들을 다뤄 시리즈를 마무리하겠습니다.