제네릭 제약 조건
extends로 타입 매개변수가 갖추어야 할 속성과 키, 생성자의 관계를 정해 구현 안에서 쓸 수 있는 연산을 넓히는 방법을 다루고, 생성자와 인수를 같은 튜플로 잇는 팩토리, 유니온 제약 안의 좁히기, 객체 병합 제약의 한계를 확인합니다.
3장의 제네릭 함수에서, T extends Lengthwise처럼 타입 매개변수에 조건을 붙이면 본문에서 length를 쓸 수 있다는 것을 보았습니다. 타입 매개변수가 어떤 타입이든 받을 수 있다면 본문에서 할 수 있는 일이 거의 없고, 반대로 너무 많은 조건을 붙이면 쓸 수 있는 곳이 줄어듭니다. 제약 조건(constraint)은 구현이 실제로 필요로 하는 최소한의 능력을 적는 도구입니다.
이 글은 속성과 키, 생성자, 유니온, 객체라는 다섯 가지 제약을 차례로 보며, 각 제약이 본문에서 무엇을 허용하고 무엇은 보장하지 않는지 확인합니다. 예제는 ts-first 프로젝트에 constraints.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
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 검사와 구분하세요.
위 그림의 화살표는 타입 사이의 관계입니다. T가 Person일 때 허용되는 키는 name과 age이고, 그 가운데 age를 고르면 반환 타입이 number로 정해지는 것처럼, 제약은 실행 순서가 아니라 타입끼리의 조건을 적습니다. 그림의 getLength 설명은 { length: 10 }이 10을 돌려준다고 적었지만, 다음 절의 예제는 길이로 맞지 않는 값도 통과한다는 것을 보이려고 { length: -2 }를 넘겨 -2를 출력합니다.
속성을 요구하는 제약
T extends 타입은 "T는 그 타입에 할당될 수 있어야 한다"는 뜻입니다. 3장에서 본 것처럼 length 속성을 요구하면, 본문에서 arg.length를 쓸 수 있습니다.
interface Lengthwise {
length: number;
}
function getLength<T extends Lengthwise>(arg: T): number {
return arg.length;
}
console.log(getLength("hello world"), getLength([1, 2, 3, 4, 5]), getLength({ length: -2 }));11 5 -2문자열과 배열은 length가 있어서 통과하고, getLength(123)은 Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.로 진단됩니다. 그런데 { length: -2 }도 length가 숫자이므로 통과해 -2를 돌려줍니다. 제약은 속성의 모양만 요구할 뿐, 그 값이 실제 원소의 개수처럼 0 이상의 정수라는 뜻까지 담지 않으므로, 값의 조건은 본문에서 따로 검사해야 합니다.
키를 요구하는 제약
제약에는 다른 타입 매개변수를 쓸 수 있습니다. K extends keyof T는 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 };
console.log(getProperty(person, "name"), getProperty(person, "age"));철수 30getProperty(person, "age")의 반환 타입은 그림처럼 Person["age"], 곧 number입니다. 없는 키를 넘긴 getProperty(person, "address")는 Argument of type '"address"' is not assignable to parameter of type 'keyof Person'.으로 진단되므로, 키의 오타를 실행 전에 찾을 수 있습니다.
생성자를 받는 제약
클래스를 받아 인스턴스를 만들어 주는 팩토리(factory) 함수에서는, 생성자의 타입을 new (...args) => T처럼 적습니다. new로 부를 수 있고 T를 돌려주는 함수라는 뜻입니다.
class Animal {
constructor(public name: string) {}
eat() {
console.log(`${this.name}가 먹습니다.`);
}
}
class Dog extends Animal {
bark() {
console.log(`${this.name}가 멍멍 짖습니다.`);
}
}
function createInstance<T extends Animal>(Constructor: new (name: string) => T, name: string): T {
return new Constructor(name);
}
const myDog = createInstance(Dog, "바둑이");
myDog.bark();
myDog.eat();바둑이가 멍멍 짖습니다.
바둑이가 먹습니다.T가 Dog로 추론되므로 myDog의 타입은 Animal로 넓어지지 않고 Dog이며, 그래서 bark를 부를 수 있습니다. T extends Animal은 만들어진 인스턴스가 Animal의 모양과 맞아야 한다는 조건입니다. 타입은 구조로 비교하므로 Animal을 상속하지 않았어도 공개 멤버의 모양이 같은 클래스라면 통과하며, 실제 상속 관계를 확인하는 것은 아닙니다.
생성자가 받는 인수가 클래스마다 다르다면, 인수 목록까지 타입 매개변수로 받아 생성자와 나머지 인수를 같은 튜플로 이을 수 있습니다.
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컴파일러는 TaggedDog의 생성자에서 Args를 [name: string, tag: number]로 추론하고, 나머지 인수에도 같은 튜플을 요구합니다. 그래서 createAnimalInstance(TaggedDog, "구름이")는 인수가 모자라 진단되고, "7"처럼 문자열을 넘기면 tag의 타입이 맞지 않아 진단됩니다. 다만 이 검사는 정적인 것이어서, 바깥에서 들어온 값이 양수인지 같은 조건은 확인하지 않습니다.
유니온 제약과 좁히기
제약에 유니온을 쓰면 받을 수 있는 타입을 몇 가지로 한정하면서, 본문에서는 앞 장의 타입 가드로 좁혀 각 경우를 처리할 수 있습니다. 다음 printLength는 T를 string이나 string[]로 제한하고, 본문의 typeof 검사로 두 경우를 나눕니다. 두 분기 모두 data.length를 쓰지만, 앞은 글자 수이고 뒤는 요소 개수입니다.
function printLength<T extends string | string[]>(data: T): void {
if (typeof data === "string") {
console.log(`문자열 길이: ${data.length}`);
} else {
console.log(`배열 길이: ${data.length}`);
}
}
printLength("hello");
printLength(["a", "b", "c"]);문자열 길이: 5
배열 길이: 3printLength(123)은 숫자가 string | string[]에 할당되지 않아 진단됩니다. 이 함수처럼 반환 타입이 T와 관계없다면, 사실 제네릭 없이 매개변수를 string | string[]로 적어도 같은 일을 합니다. 타입 매개변수는 입력의 구체적인 타입을 반환 타입이나 다른 매개변수와 이어야 할 때 쓰는 것이며, 그런 관계가 없다면 더 단순한 쪽을 고릅니다.
객체 제약과 병합의 한계
T extends object는 원시 값이 아닌 값만 받게 하므로, 두 객체를 전개 구문으로 합치는 함수에 쓸 수 있습니다. 다음 mergeObjects는 반환 타입을 두 타입의 인터섹션 T & U로 적으므로, 결과 merged에서 양쪽 객체의 속성 a, b, c를 모두 꺼내 쓸 수 있습니다.
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 });
console.log(merged.a, merged.b, merged.c);1 hello true두 객체의 키가 겹치지 않으므로 결과를 T & U로 읽어도 문제가 없고, mergeObjects(1, {})는 숫자가 object가 아니어서 진단됩니다. 하지만 전개 구문은 같은 키가 있으면 오른쪽 값으로 덮어쓰는데, 인터섹션 T & U는 양쪽 타입을 동시에 요구합니다. 그래서 키가 겹치는 객체를 합치면 결과의 타입이 실제 값과 어긋날 수 있으며, 이 차이는 연습 문제에서 확인합니다. 제약을 쓰는 방법은 공식 Handbook의 제네릭 제약에 정리되어 있습니다.
더 깊이: 제네릭 전개와 구체적인 타입의 전개
mergeObjects의 반환 타입을 적지 않아도 결과는 T & U로 추론됩니다. TypeScript 3.2부터 객체 리터럴 안에서 제네릭 타입의 값을 전개하면, Object.assign의 타입처럼 인터섹션 타입이 만들어지기 때문입니다. 아직 정해지지 않은 T와 U의 키는 컴파일러도 알 수 없으므로, 어느 쪽이 덮어쓸지 계산하지 못하고 두 타입을 그대로 겹쳐 둡니다.
반면 타입이 이미 정해진 객체를 전개하면 컴파일러는 실제 전개 구문의 규칙대로 계산합니다. 같은 이름의 속성은 가장 오른쪽의 타입을 쓰고, 메서드가 아닌 속성만 남기며, 호출 시그니처는 버립니다. 그래서 릴리스 노트의 예처럼 b: number와 b: string을 가진 두 객체를 직접 { ...x, ...y }로 합치면 b는 string이지만, 같은 두 객체를 제네릭 함수로 합치면 b는 number & string이 됩니다. 같은 3.2에서 제네릭 값의 나머지 구조 분해도 허용되었는데, const { tag, ...rest } = obj의 rest는 Pick<T, Exclude<keyof T, "tag">>로 계산됩니다.
정리
- 제약 조건
T extends 타입은T가 그 타입에 할당되어야 한다는 조건이며, 본문에서 그 타입의 멤버를 쓸 수 있게 합니다. - 제약은 모양만 요구하므로,
length가 0 이상인지 같은 값의 조건은 본문에서 따로 검사합니다. K extends keyof T처럼 제약에 다른 타입 매개변수를 쓰면, 두 타입 매개변수 사이의 관계를 정할 수 있습니다.- 생성자 타입
new (...args: Args) => T로 인스턴스의 구체적인 타입과 생성자의 인수 목록을 함께 지킬 수 있습니다. - 입력과 출력을 잇는 관계가 없다면 제네릭보다 유니온 매개변수가 단순하고, 객체를 합치는
T & U는 키가 겹칠 때 실제 값과 어긋날 수 있습니다.
연습 문제
1. 다른 객체의 키
const book = { title: "기초", pages: 48 };일 때 getProperty(book, "pages")의 반환 타입과 값은 무엇인가요? getProperty(book, "price")는 어떻게 되나요?
해설 보기
K가 "pages"로 정해지므로 반환 타입은 number이고 값은 48입니다. "price"는 book의 키가 아니어서 "title" | "pages"에 할당되지 않으므로 진단됩니다. 키의 목록은 객체의 타입에서 오므로, 객체에 속성을 더하면 넘길 수 있는 키도 함께 늘어납니다.
2. 인수를 바꾼 팩토리
createAnimalInstance(TaggedDog, "구름이", 8)의 반환 타입과 출력은 무엇인가요? createAnimalInstance(Dog, "바둑이")는 검사를 통과하나요?
해설 보기
반환 타입은 그대로 TaggedDog이고 출력만 구름이 8로 바뀝니다. 타입 추론은 인스턴스의 구체적인 타입을 지킬 뿐 값까지 고정하지 않습니다. Dog는 생성자를 따로 적지 않아 Animal의 생성자 (name: string)을 물려받으므로 Args가 [name: string]이 되고, 인수 하나를 넘긴 이 호출은 통과합니다.
3. 키가 겹치는 병합
const clash = mergeObjects({ id: 1 }, { id: "a" });에서 clash.id의 타입과 실제 값은 무엇인가요?
해설 보기
반환 타입은 { id: number } & { id: string }이고, 그 id는 number & string, 곧 never입니다. 하지만 전개 구문은 오른쪽 값으로 덮어쓰므로 실제 값은 문자열 "a"입니다. 타입이 never인 속성에 실제 값이 들어 있는 어긋난 상태이므로, 키가 겹칠 수 있다면 반환 타입을 Omit<T, keyof U> & U처럼 덮어쓰기를 반영해 적어야 합니다.
다음 글 제네릭 클래스와 인터페이스에서는 함수가 아니라 클래스와 인터페이스에 타입 매개변수를 두어, 여러 메서드가 같은 타입을 함께 쓰게 만드는 방법을 다룹니다.