제네릭 제약 조건
extends로 제네릭 타입이 가져야 할 속성·클래스·생성자 관계를 제한해 구현 내부에서 허용된 연산을 안전하게 사용합니다.
앞서 3장 4절 제네릭 함수에서 제네릭의 기본적인 개념을 다루면서, 타입 변수 T가 모든 타입을 허용하는 것이 아니라 특정 조건을 만족하는 타입만 허용하도록 제한할 수 있다고 간략하게 설명했습니다.
이것이 바로 제네릭 제약 조건(Generic Constraints)입니다.
제네릭 제약 조건은 extends 키워드를 사용하여 정의하며, 특정 타입 변수가 특정 인터페이스, 클래스 또는 다른 타입에 할당 가능해야 한다는 제약을 설정합니다.
이를 통해 제네릭 함수나 클래스 내부에서 타입 변수에 대해 더 구체적인 작업을 수행할 수 있도록 타입 안정성을 확보할 수 있습니다.
extends를 사용한 제약 조건의 기본
TYPE / RUNTIME / CONTRACT
구현에 필요한 속성과 타입 관계를 제약으로 적는다
extends는 T가 제약 타입에 할당 가능해야 한다는 조건입니다. length 접근, 유효 키, 생성 결과, 객체 병합은 서로 다른 계약입니다.
화살표는 타입 관계를 나타냅니다. Person의 키 중 age를 선택하면 반환 타입은 number입니다. 실제 값 30을 읽는 실행은 본문의 getProperty 호출이 수행합니다.
LENGTHWISE
필요한 속성만 요구
getLength는 length:number를 읽습니다. "hello world"는 11, 다섯 원소 배열은 5, {length:10}은 10입니다. 숫자 123은 이 조건을 만족하지 않습니다.
KEY RELATION
키와 반환 타입을 연결
K extends keyof T는 Person의 name 또는 age를 받습니다. T[K]가 각각 string 또는 number를 반환하며 "address"는 허용하지 않습니다.
CONSTRUCTOR
생성 결과와 인자는 별개
createInstance(Dog, "바둑이")는 Dog를 반환합니다. T extends Animal은 인스턴스 호환성을 검사합니다. any[] 생성자 인자는 실제 인수 튜플을 보존하지 않습니다.
OBJECT SPREAD
덮어쓰기와 교차 타입
mergeObjects는 object 입력을 받으며 본문의 서로 다른 키는 함께 보존됩니다. 충돌 키는 뒤 값이 덮어쓰므로 단순 T & U가 모든 충돌을 정확히 표현하지는 않습니다.
공개 멤버만 있는 Animal의 제약은 구조적으로 호환되는 값도 허용합니다. 실제 상속 계보만을 확인하는 instanceof 검사와 구분하세요.
제네릭 제약 조건은 타입 변수 뒤에 extends 키워드와 함께 제약 타입을 명시하여 선언합니다.
// T extends ConstraintTypeT: 제약을 걸 타입 변수.extends:T가ConstraintType에 할당 가능해야 함을 의미하는 키워드.ConstraintType:T가 만족해야 할 조건 (인터페이스, 클래스, 원시 타입 등).
가장 기본적인 예시로, 특정 속성을 가진 객체만 받을 수 있도록 제약을 걸어보겠습니다.
interface Lengthwise {
length: number; // length 속성이 number 타입이어야 한다고 정의
}
// getLength 함수는 T 타입의 인자를 받지만,
// T는 반드시 Lengthwise 인터페이스를 확장(만족)해야 합니다.
function getLength<T extends Lengthwise>(arg: T): number {
return arg.length; // 이제 arg.length에 안전하게 접근할 수 있습니다.
}
// Lengthwise 인터페이스를 만족하는 타입들:
console.log(getLength("hello world")); // string은 length 속성을 가짐: 11
console.log(getLength([1, 2, 3, 4, 5])); // 배열은 length 속성을 가짐: 5
console.log(getLength({ length: 10, value: "test" })); // length 속성을 가진 객체 리터럴: 10
// Lengthwise 인터페이스를 만족하지 않는 타입:
// getLength(123); // Error: 'number' 형식은 'Lengthwise' 형식에 할당할 수 없습니다.
// getLength(true); // Error: 'boolean' 형식은 'Lengthwise' 형식에 할당할 수 없습니다.
// getLength({}); // Error: '{}' 형식에 'length' 속성이 없습니다.getLength 함수에서 T extends Lengthwise 제약 조건을 추가함으로써, 타입스크립트 컴파일러는 arg가 반드시 length: number 속성을 가질 것임을 알게 됩니다.
따라서 함수 내부에서 arg.length에 접근하는 것이 타입 오류 없이 가능해집니다.
속성의 모양과 값의 조건을 구별하기
이 제약은 length가 숫자라는 사실만 요구합니다. getLength({ length: -2 })도 타입 검사를 통과하고 -2를 반환합니다. 실제 원소 수처럼 0 이상의 정수만 허용하려면 별도의 값 검사가 필요합니다. 문자열이나 배열의 길이와 임의 객체의 length 속성은 같은 타입으로 읽을 수 있어도 같은 의미를 자동으로 갖지는 않습니다.
여러 타입 변수에 제약 조건 적용
제네릭 함수나 클래스가 여러 타입 변수를 가질 때, 각각의 타입 변수에 독립적인 제약 조건을 적용할 수 있습니다.
// K는 T의 키 타입에 할당 가능해야 함을 제약합니다.
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface Person {
name: string;
age: number;
}
const person: Person = { name: "철수", age: 30 };
let personName = getProperty(person, "name"); // type personName = string
let personAge = getProperty(person, "age"); // type personAge = number
// getProperty(person, "address"); // Error: 'address' 형식은 'keyof Person' 형식에 할당할 수 없습니다.여기서 K extends keyof T는 K가 T의 모든 속성 이름으로 구성된 유니온 타입('name' | 'age') 중 하나여야 한다는 제약을 설정합니다.
이를 통해 getProperty 함수는 전달된 obj의 유효한 키만 받도록 강제하여 타입 안전성을 높입니다.
제약 조건으로서의 class 타입
클래스를 제약으로 사용하면 그 인스턴스 타입과의 호환성을 검사합니다. 공개 멤버만 있는 아래 Animal은 구조적으로 호환되는 값도 허용하므로 실제 상속 계보만을 검사하는 것은 아닙니다.
이는 특히 팩토리 함수(Factory function)를 만들 때 유용합니다.
class Animal {
constructor(public name: string) {}
eat() { console.log(`${this.name}가 먹습니다.`); }
}
class Dog extends Animal {
constructor(name: string) { super(name); }
bark() { console.log(`${this.name}가 멍멍 짖습니다.`); }
}
class Cat extends Animal {
constructor(name: string) { super(name); }
purr() { console.log(`${this.name}가 야옹거립니다.`); }
}
// createInstance 함수는 T extends Animal 클래스의 생성자를 받습니다.
// new (...args: any[]) => T : T 타입의 인스턴스를 생성하는 생성자 시그니처
function createInstance<T extends Animal>(Constructor: new (...args: any[]) => T, name: string): T {
return new Constructor(name);
}
const myDog = createInstance(Dog, "바둑이"); // myDog의 타입은 Dog
myDog.bark(); // 바둑이(이)가 멍멍 짖습니다.
myDog.eat(); // 바둑이(이)가 먹습니다.
const myCat = createInstance(Cat, "나비"); // myCat의 타입은 Cat
myCat.purr(); // 나비(이)가 야옹거립니다.
// const myAnimal = createInstance(Animal, "복슬이"); // myAnimal의 타입은 Animal
// myAnimal.bark(); // Error: 'Animal' 형식에 'bark' 속성이 없습니다.
// createInstance(String, "abc"); // Error: 'String' 형식은 'new (...args: any[]) => Animal' 형식에 할당할 수 없습니다.여기서 T extends Animal은 생성 결과의 인스턴스 타입이 Animal의 계약과 호환되어야 한다는 뜻입니다.
Constructor: new (...args: any[]) => T는 Constructor 매개변수가 T 타입 인스턴스를 생성할 수 있는 생성자 함수임을 나타냅니다.
이 예제의 Dog·Cat은 문자열 하나를 받으므로 호출과 맞습니다. 다만 생성자 매개변수의 any[]는 실제 인수 튜플을 보존하지 않습니다. 그림의 생성자 계약을 일반화하려면 전달 인자와 생성자 매개변수 타입의 관계도 모델링해야 합니다.
생성자와 전달 인자를 같은 튜플로 연결하기
생성 결과의 타입과 생성자에 넣을 인자는 서로 다른 계약입니다. 위 Animal 선언이 있는 파일에서 아래 함수를 별도로 추가합니다. Args는 인자의 순서·개수·타입을 가진 튜플이고, 생성자와 나머지 매개변수가 같은 Args를 사용합니다. T는 만들어진 인스턴스의 타입을 보존합니다.
function createAnimalInstance<Args extends unknown[], T extends Animal>(
Constructor: new (...args: Args) => T,
...args: Args
): T {
return new Constructor(...args);
}
class TaggedDog extends Animal {
constructor(name: string, public tag: number) {
super(name);
}
}
const taggedDog = createAnimalInstance(TaggedDog, '구름이', 7);
console.log(taggedDog.name, taggedDog.tag); // 구름이 7
// createAnimalInstance(TaggedDog, '구름이'); // 예상 오류: tag 인자가 빠짐
// createAnimalInstance(TaggedDog, '구름이', '7'); // 예상 오류: tag는 number생성자에서 [name: string, tag: number]를 추론하면 전달 인자에도 같은 튜플을 요구합니다. 결과는 Animal로 넓어지지 않고 TaggedDog이므로 tag를 읽을 수 있습니다. 정적 검사가 외부 JSON의 값을 검증하거나 tag의 양수 여부를 검사하는 것은 아닙니다.
직접 계약을 적용하기
같은 파일에 const book = { title: '기초', pages: 48 };을 선언했다고 가정합니다. 원래 getProperty에 book과 "pages"를 전달할 때 반환 타입과 값을 적고, "price"를 전달할 수 있는지 판단해 보세요. 위 안전한 팩토리에서 TaggedDog의 두 번째 인자를 8로 바꾸면 반환 타입과 출력이 각각 어떻게 달라질까요?
키와 생성자 관계 확인
K는 "pages", T[K]는 number이므로 값은 48입니다. "price"는 keyof typeof book에 없어서 거부됩니다. 팩토리의 결과 타입은 계속 TaggedDog이고, 출력만 구름이 8로 달라집니다. 타입 추론은 인스턴스의 구체 타입을 보존하며 실행할 값까지 같은 값으로 고정하지는 않습니다.
공식 제네릭 설명의 키 제약과 생성자 타입 예시를 대조할 수 있습니다. 위 튜플 팩토리와 문제는 인자 관계를 확인하기 위한 교재의 확장 예시입니다.
유니온 제약 안에서 typeof로 좁히기
특정 원시 타입이나 리터럴 타입에 대해 제약을 걸 수도 있습니다.
function printLengthIfString<T extends string | string[]>(data: T): void {
if (typeof data === 'string') {
console.log(`문자열 길이: ${data.length}`);
} else {
console.log(`배열 길이: ${data.length}`);
}
}
printLengthIfString("hello"); // 문자열 길이: 5
printLengthIfString(["a", "b", "c"]); // 배열 길이: 3
// printLengthIfString(123); // Error: 'number' 형식은 'string | string[]' 형식에 할당할 수 없습니다.이 예시에서는 T extends string | string[]를 사용하여 data가 string 또는 string 배열이어야 함을 강제합니다.
제약 조건과 타입 추론
제네릭 제약 조건은 타입 추론에도 영향을 미칩니다.
타입스크립트는 제약 조건을 기반으로 타입 변수의 최소한의 형태를 추론할 수 있습니다.
function mergeObjects<T extends object, U extends object>(obj1: T, obj2: U): T & U {
return { ...obj1, ...obj2 };
}
const merged = mergeObjects({ a: 1, b: "hello" }, { c: true, d: [1, 2] });
// merged의 타입은 { a: number; b: string; } & { c: boolean; d: number[]; }
// 이는 { a: number; b: string; c: boolean; d: number[]; } 와 동일합니다.
// mergeObjects(1, {}); // Error: 'number' 형식은 'object' 형식에 할당할 수 없습니다.T extends object와 U extends object 제약 조건 덕분에, 함수 내부에서 obj1과 obj2가 객체 타입이며 스프레드 문법(...)을 안전하게 사용할 수 있음을 보장받습니다.
위 호출은 두 객체의 키가 겹치지 않아 T & U로 결과를 읽을 수 있습니다. 일반적으로 spread는 오른쪽 값으로 덮어쓰므로 충돌 키가 있을 때의 런타임 결과를 단순 교차 타입이 정확하게 표현한다고 보장하지는 않습니다.
제네릭 제약 조건은 타입스크립트에서 제네릭의 유연성과 타입 안전성을 동시에 확보하는 핵심적인 메커니즘입니다.
이를 통해 우리는 모든 타입을 무분별하게 허용하는 대신, 필요한 조건을 명시하여 더 강력하고 예측 가능한 제네릭 컴포넌트들을 설계할 수 있습니다.
extends 키워드를 사용하여 인터페이스, 클래스, 또는 다른 타입을 제약 조건으로 활용하는 방법을 숙지하는 것이 중요합니다.
제약 조건은 타입을 좁히는 장치이면서 함수 내부에서 무엇을 안전하게 사용할 수 있는지 알려주는 설계 도구입니다.
제네릭 제약은 입력을 제한하는 데서 끝나지 않습니다.
함수 내부에서 필요한 멤버를 안전하게 쓰면서도 호출자가 넣은 구체 타입이 반환까지 보존되는지 함께 확인해야 합니다.