제네릭 제약 조건
앞서 3장 4절 "제네릭 함수"에서 제네릭의 기본적인 개념을 다루면서, 타입 변수 T
가 모든 타입을 허용하는 것이 아니라 특정 조건을 만족하는 타입만 허용하도록 제한할 수 있다고 간략하게 설명했습니다. 이것이 바로 제네릭 제약 조건(Generic Constraints) 입니다.
제네릭 제약 조건은 extends
키워드를 사용하여 정의하며, 특정 타입 변수가 특정 인터페이스, 클래스 또는 다른 타입에 할당 가능해야 한다는 제약을 설정합니다. 이를 통해 제네릭 함수나 클래스 내부에서 타입 변수에 대해 더 구체적인 작업을 수행할 수 있도록 타입 안정성을 확보할 수 있습니다.
extends
를 사용한 제약 조건의 기본
제네릭 제약 조건은 타입 변수 뒤에 extends
키워드와 함께 제약 타입을 명시하여 선언합니다.
// T extends ConstraintType
T
: 제약을 걸 타입 변수.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
에 접근하는 것이 타입 오류 없이 가능해집니다.
여러 타입 변수에 제약 조건 적용
제네릭 함수나 클래스가 여러 타입 변수를 가질 때, 각각의 타입 변수에 독립적인 제약 조건을 적용할 수 있습니다.
// 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
타입
제네릭 타입 변수에 특정 클래스를 제약 조건으로 사용하여, 해당 클래스의 인스턴스 또는 그 자식 클래스의 인스턴스만 허용하도록 할 수 있습니다. 이는 특히 팩토리 함수(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
은 T
가 Animal
클래스 또는 Animal
을 상속받는 모든 클래스 타입임을 의미합니다. 그리고 Constructor: new (...args: any[]) => T
는 Constructor
매개변수가 T
타입의 인스턴스를 생성할 수 있는 생성자 함수임을 나타냅니다. 덕분에 createInstance
함수는 다양한 Animal
서브클래스의 인스턴스를 타입 안전하게 생성할 수 있게 됩니다.
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
는 두 객체 타입의 모든 속성을 포함하는 인터섹션 타입으로 정확하게 추론됩니다.
제네릭 제약 조건은 타입스크립트에서 제네릭의 유연성과 타입 안전성을 동시에 확보하는 핵심적인 메한즘입니다. 이를 통해 우리는 모든 타입을 무분별하게 허용하는 대신, 필요한 조건을 명시하여 더 강력하고 예측 가능한 제네릭 컴포넌트들을 설계할 수 있습니다. extends
키워드를 사용하여 인터페이스, 클래스, 또는 다른 타입을 제약 조건으로 활용하는 방법을 숙지하는 것이 중요합니다.