지난 시간에는 함수 타입을 다루며 제네릭과 첫 만남을 가졌습니다. 이번에는 그 위에서 한 걸음 더 들어가서 제네릭의 진짜 표현력을 끌어내는 도구들 — 제약, 다중 타입 매개변수, 제네릭 인터페이스/클래스, keyof, 인덱스 액세스 타입을 정리합니다.
이번 글의 내용은 처음엔 어렵게 느껴질 수 있습니다. 모든 패턴을 한 번에 외울 필요는 없고, **"이런 도구가 있다"**는 감각만 잡으셔도 충분해요.
제약 복습 — extends
#5에서 잠깐 봤던 제약. 제네릭 타입에 조건을 거는 도구입니다.
function logLength<T extends { length: number }>(value: T): void {
console.log(value.length);
}
logLength('hello'); // ✓
logLength([1, 2, 3]); // ✓
logLength(42); // 🚫T extends 모양은 "T는 그 모양과 호환되는 어떤 타입"이라는 뜻이에요. 함수 본문에서 그 모양에 의존하는 코드를 안전하게 작성할 수 있게 됩니다.
다중 타입 매개변수
여러 타입 변수가 함께 쓰일 때, 그들 사이에 관계를 표현할 수 있습니다.
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 T는 T의 모든 키 이름의 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>에서 일어나는 일:
- 호출자가
getProperty(user, 'id')로 호출 T는user의 타입(User)으로 추론됨K는'id'로 추론됨- 반환 타입
T[K]는User['id']=string
타입 변수들이 서로 연결되면서 getProperty(user, 'id')의 반환 타입이 정확히 string이 됩니다. 잘못된 키('foo')를 넘기면 K extends keyof T 제약을 위반해 컴파일 에러.
이게 자바스크립트로는 표현할 수 없는 타입스크립트의 진짜 표현력이에요.
제네릭 인터페이스와 type alias
함수만이 아니라 타입 별명 자체도 제네릭으로 만들 수 있습니다.
type Box<T> = {
value: T;
};
const a: Box<number> = { value: 42 };
const b: Box<string> = { value: 'hello' };Box는 어떤 타입이든 담을 수 있는 일반 컨테이너입니다. 사용 시점에 Box<number>처럼 타입을 채워서 구체화하죠.
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 대신 결과 객체를 반환하는 거예요. 큰 코드베이스에서 인기 있는 스타일입니다.
제네릭 클래스
클래스도 제네릭으로 만들 수 있습니다.
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) 구현이 제네릭 클래스의 가장 자연스러운 사용처입니다.
조건부 타입 — 살짝 맛보기
타입 자체에서 조건문을 쓸 수 있다는 게 신기한 기능 중 하나입니다.
type IsString<T> = T extends string ? true : false;
type A = IsString<'hello'>; // true
type B = IsString<42>; // falseT 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 타입
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. 함수의 반환 타입 추출
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;
}A와 B가 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(제네릭) — 호출자가 타입 결정. 함수가 그 타입을 보존해줌
데이터를 그대로 통과시키는 함수라면 거의 항상 제네릭이 정답입니다. any나 unknown을 쓰면 호출자가 타입 정보를 잃어요.
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입니다.
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에 한 번 정의되면, on과 emit 호출이 모두 그 정의를 따라 자동으로 검사됩니다. 잘못된 페이로드를 emit하면 컴파일 에러.
자바스크립트로는 코드 + 주석으로 표현해야 했던 것이 타입으로 강제됩니다. 이런 게 타입스크립트의 매력이에요.
마무리
이번 글에서는 제네릭의 깊은 도구들을 다뤘습니다.
- 제약 (
extends) — 타입 변수에 조건 걸기 - 다중 타입 매개변수 — 변수들 사이의 관계 표현
keyof T— 객체 키들의 union 타입- 인덱스 액세스
T[K]— 객체의 특정 키의 값 타입 - 제네릭 type alias / interface / class — 타입 모양에 변수 끼우기
- conditional types와
infer— 타입 수준 패턴 매칭 (살짝 맛보기) - 매핑된 타입 — 객체의 각 프로퍼티 변환
여기까지 오면 타입스크립트의 거의 모든 표현력을 손에 잡은 셈이에요. 마지막 한 글이 남았습니다. "타입스크립트 기초 강좌 #7 유틸리티 타입과 tsconfig"에서는 이번 글에서 살짝 짚은 Partial, Pick, Omit, ReturnType 같은 표준 유틸리티 타입들을 한 번에 정리하고, 컴파일 동작을 결정하는 **tsconfig.json**의 핵심 옵션들을 다뤄 시리즈를 마무리하겠습니다.