제네릭(generic, 타입을 쓸 때 정하게 미뤄두는 빈칸)으로 빈칸을 하나 뚫어놓으면 함수 하나로 온갖 타입을 상대할 수 있어 편하다. 그런데 그 빈칸이 너무 텅 비어서 오히려 손발이 묶이는 순간이 온다. T가 무엇이든 될 수 있다는 말은, 뒤집으면 T에 대해 아무것도 가정하지 못한다는 뜻이기도 하다. 길이를 재고 싶어도 T가 숫자일지 모르니 편집기가 막고, 특정 키를 꺼내고 싶어도 그 키가 있다는 보장이 없으니 못 꺼낸다. 여기서 필요한 게 제약(constraint)과 기본값(default)이다. 아래 코드는 전부 tsc(타입스크립트 컴파일러)로 돌려 확인했고, 타입 에러도 실제로 나온 메시지 그대로다.
제약 없는 T는 아무 속성도 못 읽는다. 두 값 중 더 긴 쪽을 돌려주는 함수를 짠다고 하자. 길이를 비교하려고 .length를 읽는 순간 편집기가 빨간 줄을 긋는다.
function longest<T>(a: T, b: T) {
return a.length > b.length ? a : b;
}
// error TS2339: Property 'length' does not exist on type 'T'.
T가 문자열이면 length가 있지만, 숫자거나 불리언이면 없다. 타입스크립트는 최악을 가정하니, 있을지 없을지 모르는 속성은 아예 못 읽게 막는다. 빈칸이 완전히 열려 있어서 생기는 대가다.
extends로 최소 조건을 건다. 제약은 <T extends 조건> 꼴로 단다. 여기서 extends는 클래스 상속이 아니라 "T는 아무거나 되지만, 적어도 이건 만족해라"라는 하한선이다. length를 가진 무엇이든 받겠다고 조건을 걸어보자.
interface HasLength { length: number; }
function longest<T extends HasLength>(a: T, b: T): T {
return a.length > b.length ? a : b;
}
const r1 = longest("apple", "kiwi"); // r1의 타입: string
const r2 = longest([1, 2, 3], [4, 5]); // r2의 타입: number[]
이제 T 안엔 length: number가 있다고 보장되니 .length를 마음 놓고 읽는다. 그러면서도 넣은 타입은 그대로 따라 나온다. 문자열을 넣으면 r1이 문자열, 숫자 배열을 넣으면 r2가 number[]다. any로 뭉갰다면 잃었을 정보를 제약을 걸고도 다 지켰다. 조건을 달았다고 빈칸이 막힌 게 아니라, 딱 필요한 만큼만 좁힌 것이다.
조건을 못 채우면 넘기는 단계에서 막힌다. 문자열이나 배열은 length가 있어 통과하지만, 숫자는 없다. 그래서 숫자를 넣으면 함수 안이 아니라 호출하는 자리에서 걸린다.
longest(10, 20);
// error TS2345: Argument of type 'number' is not assignable
// to parameter of type 'HasLength'.
실행해보고 나서야 아는 게 아니라, 애초에 넣지도 못하게 막힌다는 게 핵심이다. 제약은 함수 몸통을 지켜줄 뿐 아니라, 잘못된 인자를 문 앞에서 돌려보내는 문지기 노릇도 한다.
keyof로 그 객체가 진짜 가진 키만 받는다. 객체에서 값을 꺼내는 getProp를 짠다고 하자. 키를 그냥 string으로 받으면 있지도 않은 키를 넘겨도 통과하고, 반환 타입도 뭉개진다. keyof(객체 타입의 키들을 유니온으로 뽑는 연산자)와 제약을 엮으면 이걸 잡는다.
function getProp<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: "Kim", age: 30 };
const nm = getProp(user, "name"); // nm의 타입: string
const ag = getProp(user, "age"); // ag의 타입: number
K extends keyof T는 "K는 T가 실제로 가진 키 중 하나"라는 제약이다. user의 키는 "name"과 "age"뿐이니 K도 그 둘 중 하나로 좁혀진다. 반환은 T[K], 즉 그 키가 가리키는 값의 타입이라 "name"을 넘기면 문자열, "age"를 넘기면 숫자가 정확히 나온다. 키 이름과 값 타입의 관계까지 제네릭이 기억해주는 셈이다.
없는 키는 오타까지 잡아준다. 이 제약의 진짜 이득은 오타 방어다. 있지도 않은 "email"을 넘기면 그 자리에서 막힌다.
getProp(user, "email");
// error TS2345: Argument of type '"email"' is not assignable
// to parameter of type '"age" | "name"'.
허용되는 키가 "age" | "name"이라고 에러 메시지가 후보까지 대준다. 나는 예전에 서버 응답 객체에서 user.emial 식으로 오타 낸 걸 몇 시간 뒤 로그 보고 찾은 적이 있는데, 이 제약 하나면 그런 삽질은 편집기에서 끝난다.
기본 타입 매개변수로 T를 생략하게 한다. 제약이 T를 좁히는 장치라면, 기본값은 T를 아예 안 넘겨도 굴러가게 하는 장치다. 타입 매개변수 뒤에 = 기본타입을 붙이면, 호출할 때 타입을 안 주면 그 기본이 쓰인다. 함수 인자의 기본값과 똑같은 발상이다.
interface Box<T = string> {
value: T;
}
const b1: Box = { value: "hi" }; // T = string
const b2: Box<number> = { value: 5 }; // T = number
Box라고만 쓰면 T가 기본값 string으로 채워져 value가 문자열이 되고, Box<number>처럼 명시하면 그걸로 덮어쓴다. 대부분 문자열을 담는데 가끔 다른 걸 담는 자료구조라면, 흔한 쪽을 기본값으로 깔아두면 매번 꺾쇠를 적는 수고가 준다.
제약과 기본값은 같이 걸 수 있다. 둘은 배타적이지 않다. <T extends 조건 = 기본> 순서로 붙이면 "조건은 만족하되, 안 주면 이걸로"가 된다. 다만 기본값도 그 제약을 통과해야 한다.
interface Animal { name: string; }
interface Collection<T extends Animal = Animal> {
items: T[];
}
const bad: Collection<number> = { items: [1] };
// error TS2344: Type 'number' does not satisfy the constraint 'Animal'.
number는 name이 없어 Animal 제약을 못 넘기니, 기본값이 있든 없든 이 단계에서 막힌다. 에러 문구도 "제약을 만족하지 않는다(does not satisfy the constraint)"라고 assignable과는 다르게 나와서, 인자 문제가 아니라 타입 인자 문제임을 알려준다.
정리하면 제약은 빈칸을 필요한 만큼만 좁히는 하한선이고, 기본값은 빈칸을 안 채웠을 때 대신 들어가는 값이다. 제네릭 1이 "빈칸을 뚫는" 이야기였다면, 여기서는 그 빈칸에 난간을 두르고 바닥을 깔았다. 남의 라이브러리 타입을 읽다 보면 <T extends ... = ...>가 사방에 박혀 있는데, 이제 그게 무슨 소린지 눈에 들어올 것이다.