제네릭 제약 — extends와 keyof
TypeScript 제네릭의 extends 제약, keyof 연산자, 인덱스 접근 타입 T[K], 생성자 제약, 재귀 제약을 예제 중심으로 완전히 정리합니다. 과도한 제약이 오히려 유연성을 해치는 경우도 다룹니다.
지난 글에서 제네릭의 기본 개념과 타입 매개변수 추론을 살펴봤다. 이번에는 **제네릭 제약(Generic Constraints)**을 다룬다. 제약은 타입 매개변수 T가 가질 수 있는 타입의 범위를 한정하여, T에서 특정 속성이나 메서드를 안전하게 사용할 수 있게 한다.
extends 제약 — 기본
extends 키워드로 T가 반드시 특정 구조를 가지도록 강제한다.
// T는 name: string을 가져야 한다
function greet<T extends { name: string }>(entity: T): string {
return `Hello, ${entity.name}`;
}
greet({ name: "Alice", age: 30 }); // ✅ 추가 속성 허용
greet({ name: "Bob" }); // ✅
greet({ age: 25 }); // TS2345 ❌ name 없음
greet("Alice"); // TS2345 ❌ string에 name 없음
extends에 지정한 타입은 최소 조건이다. T가 그 구조를 충족하는 한 추가 속성이 있어도 된다.
keyof — 객체 키를 타입으로
keyof T는 타입 T의 모든 키를 유니언으로 반환한다.
interface Config {
host: string;
port: number;
secure: boolean;
}
type ConfigKey = keyof Config;
// "host" | "port" | "secure"
// keyof typeof — 값 객체의 키 추출
const STATUS = { active: 1, inactive: 0, pending: 2 } as const;
type StatusKey = keyof typeof STATUS;
// "active" | "inactive" | "pending"
keyof는 제네릭과 결합했을 때 특히 강력해진다.
T[K] — 인덱스 접근 타입
T[K]는 타입 T에서 키 K에 해당하는 값의 타입을 추출한다.
type Age = Config["port"]; // number
type AllValues = Config[keyof Config]; // string | number | boolean
// 배열 요소 타입 추출
type Element = string[][number]; // string
K extends keyof T와 T[K]를 조합하면 런타임에서나 가능했던 동적 접근을 타입 안전하게 만들 수 있다.
실전 패턴
// 특정 타입의 키만 허용하는 pluck
function pluck<T, K extends keyof T>(arr: T[], key: K): T[K][] {
return arr.map(item => item[key]);
}
const users = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" },
];
const names = pluck(users, "name"); // string[]
const ids = pluck(users, "id"); // number[]
pluck(users, "email"); // TS2345 ❌ 존재하지 않는 키
조건부 extends — 타입 수준의 분기
extends는 조건부 타입에서도 사용된다. 두 문맥을 구분하는 것이 중요하다.
// 제약의 extends — T는 반드시 U를 충족
function fn<T extends string>(val: T) { ... }
// 조건부 타입의 extends — T가 U를 충족하면 A, 아니면 B
type IsString<T> = T extends string ? "yes" : "no";
type R1 = IsString<string>; // "yes"
type R2 = IsString<number>; // "no"
제약의 함정 — 과도한 제약
제약이 항상 좋은 것은 아니다. 지나치게 구체적인 제약은 함수의 유연성을 떨어뜨린다.
// 과도한 제약 — 배열 타입 전체를 요구
function firstElement<T extends any[]>(arr: T): T[0] {
return arr[0];
}
// 더 나은 버전 — 요소 타입만 매개변수화
function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}
제약은 “이 함수 내에서 T의 어떤 기능을 사용해야 하는가”라는 질문에서 출발해야 한다. 함수 본문에서 사용하지 않는 속성을 제약에 포함하면 불필요하게 호출 범위가 좁아진다.
복합 제약 패턴
// 여러 인터페이스 동시 충족
function persist<T extends Identifiable & Timestamped>(
entity: T
): Promise<T> {
// ...
}
interface Identifiable { id: number }
interface Timestamped { createdAt: Date; updatedAt: Date }
// 클래스 제약 — 특정 클래스의 하위 클래스만 허용
function process<T extends Error>(err: T): string {
return `[${err.name}] ${err.message}`;
}
process(new TypeError("bad type")); // ✅
process(new Error("generic")); // ✅
process("not an error"); // TS2345 ❌
인터섹션 &으로 여러 제약을 동시에 적용할 수 있다.
지난 글: 제네릭 기초 — 재사용 가능한 타입 추상화
다음 글: 조건부 타입 — 타입 수준의 분기 처리
읽어주셔서 감사합니다. 😊