extends로 제네릭에 제약을 거는 법
아무 제약이 없는 T는 사실 아무것도 할 수 없다
제네릭을 처음 배우고 나면 흔히 이런 함수를 씁니다.
function getLength<T>(value: T): number {
return value.length; // 에러: Property 'length' does not exist on type 'T'.
}이 코드는 컴파일되지 않습니다. T는 정말 아무 타입이나 될 수 있는데, 숫자나 불리언에는 length가 없으니까요. 여기서 초심자가 자주 저지르는 실수가 value as any).length로 강제로 뚫는 것입니다. 그러면 앞 챕터에서 그렇게 피하려 했던 any가 다시 들어옵니다. 올바른 해법은 T가 "적어도 length는 가지고 있다"는 사실을 컴파일러에게 알려주는 것, 즉 제약(constraint)입니다.
function getLength<T extends { length: number }>(value: T): number {
return value.length; // OK
}
getLength("hello"); // OK, string은 length를 가진다
getLength([1, 2, 3]); // OK, 배열도 length를 가진다
getLength(42); // 에러: number는 length가 없다extends { length: number }는 클래스 상속과는 무관합니다. "T는 length: number 속성을 가진 타입이어야 한다"는 조건일 뿐입니다. 이 조건을 걸어두면 T 안에서 .length를 안전하게 쓸 수 있고, 동시에 length가 없는 타입을 넘기면 호출부에서 바로 에러가 납니다.
keyof와 결합해 객체의 키를 안전하게 다루기
제약이 가장 자주 쓰이는 곳은 객체에서 속성을 꺼내는 함수입니다.
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { id: 1, name: "eun" };
const name = getProp(user, "name"); // string
const bad = getProp(user, "email"); // 에러: 'email' 은 'id' | 'name'에 할당할 수 없다K extends keyof T가 이 함수의 핵심입니다. keyof T는 T가 가진 모든 키를 유니온 타입으로 뽑아냅니다(user라면 "id" | "name"). K를 이 유니온의 부분집합으로 제한했기 때문에, 존재하지 않는 키인 "email"을 넘기면 컴파일 시점에 바로 걸러집니다. 이 제약이 없었다면 key의 타입은 string이 되고, T[K]도 정확한 타입을 낼 수 없었을 겁니다 — 런타임에나 잡힐 오타가 컴파일 타임으로 당겨지는 겁니다.
제약을 너무 좁게 걸면 재사용성이 사라진다
제약은 강력하지만 과하게 걸면 제네릭을 쓰는 의미가 없어집니다.
// 제약이 너무 구체적이다
function printName<T extends { id: number; name: string; email: string }>(user: T) {
console.log(user.name);
}printName은 name만 읽는데 id와 email까지 요구하고 있습니다. 이렇게 쓰면 name만 가진 객체는 넘길 수 없어서, 제네릭인데도 사실상 특정 타입 하나만 받는 함수가 됩니다. 함수가 실제로 사용하는 속성만 제약에 넣는 게 원칙입니다.
function printName<T extends { name: string }>(user: T) {
console.log(user.name);
}이 원칙은 결국 "함수 시그니처는 함수가 실제로 필요로 하는 것만 요구해야 한다"는, 제네릭이 아니어도 통하는 설계 원칙과 같습니다. 제네릭 제약은 그 원칙을 타입 레벨에서 강제하는 도구일 뿐입니다.
기본 타입 매개변수와 함께 쓰기
제약과 자주 짝을 이루는 문법이 기본값입니다.
function createStore<T extends object = Record<string, unknown>>(initial?: T) {
return { value: initial ?? ({} as T) };
}
const store1 = createStore(); // T는 기본값인 Record<string, unknown>
const store2 = createStore({ count: 0 }); // T는 { count: number }로 추론= Record<string, unknown>는 호출부에서 타입 인수를 생략했을 때 사용할 기본값입니다. 함수형 컴포넌트의 props 제네릭이나 상태 관리 라이브러리의 스토어 생성 함수에서 자주 보이는 패턴인데, "타입을 지정하면 그 타입을, 안 하면 무난한 기본 타입을 쓰겠다"는 의도를 표현합니다.
제약은 결국 "이 제네릭 함수가 최소한 무엇을 알고 있어야 하는지"를 선언하는 문법입니다. 너무 느슨하면 아무것도 못 하고, 너무 빡빡하면 제네릭의 의미가 없어집니다. 다음 챕터에서는 타입 매개변수가 하나가 아니라 여러 개로 늘어날 때 이 균형이 어떻게 더 복잡해지는지 다룹니다.