같은 로직인데 다루는 타입만 다른 함수를 만나면, 보통 두 가지 나쁜 선택을 한다. 하나는 숫자용, 문자열용을 따로 복사해 두 벌을 만드는 거고, 하나는 any로 타입을 뭉개 하나로 때우는 거다. 앞엣것은 중복이고 뒤엣것은 타입스크립트를 쓰는 의미를 버리는 짓이다. 제네릭(generic)은 "타입을 미리 정하지 않고, 쓸 때 정하게" 하는 세 번째 길이다. 아래 결과는 전부 실제 타입 검사기를 돌려 나온 그대로다.
any로 뭉개면 타입이 증발한다. 아무 배열이나 받아 첫 원소를 주는 함수를 any로 짜면 당장은 돌아간다. 문제는 돌려받은 값의 타입도 any가 된다는 거다.
function firstAny(arr: any[]): any {
return arr[0];
}
const x = firstAny([1, 2, 3]);
// x의 타입: any
x는 분명 숫자인데 타입은 any다. 이러면 x.toUpperCase()처럼 숫자에 없는 걸 불러도 편집기가 안 막고, 실행 때 가서야 터진다. 타입 검사를 끄자고 타입스크립트를 쓰는 셈이니 본말이 전도된다.
제네릭은 타입을 매개변수로 받는다. 값을 매개변수로 받듯, 타입도 매개변수로 받게 하는 게 제네릭이다. 함수 이름 뒤에 꺾쇠와 대문자 한 글자(<T>)를 붙이면, T라는 "아직 안 정한 타입"이 생긴다. 관례로 Type의 첫 글자 T를 쓴다.
function identity<T>(value: T): T {
return value;
}
const s = identity("hello"); // s의 타입: "hello"
const n = identity(42); // n의 타입: 42
받은 값을 그대로 돌려주는 함수인데, T가 다리를 놓는다. 문자열 "hello"를 넣으면 T가 그 자리에서 문자열로 정해져 반환도 문자열, 숫자 42를 넣으면 T가 숫자로 정해진다. any와 달리 넣은 타입이 그대로 따라 나온다. 이게 제네릭의 전부다. 타입을 넣는 자리를 비워두고, 호출하는 순간에 채우는 것.
배열을 다룰 때 진가가 드러난다. 앞의 firstAny를 제네릭으로 고치면, 넣은 배열의 원소 타입이 반환까지 그대로 이어진다.
function first<T>(arr: T[]): T {
return arr[0];
}
const a = first([10, 20, 30]); // a의 타입: number
const b = first(["x", "y"]); // b의 타입: string
숫자 배열을 넣으니 T가 number로 정해져 a가 숫자, 문자열 배열을 넣으니 b가 문자열이 된다. 함수는 하나인데 넣는 타입마다 알맞은 타입이 나오고, a.toUpperCase() 같은 실수는 편집기에서 바로 막힌다. 복붙도 any도 안 하고 둘 다 잡은 셈이다.
타입인자는 대개 안 써도 된다. T가 뭔지 매번 적어줘야 할 것 같지만, 타입스크립트가 넘긴 값을 보고 알아서 추론한다. 위에서 <number>를 안 썼는데도 알아낸 게 그거다. 물론 원하면 직접 못박을 수도 있다.
const c = first<string>(["a", "b"]); // T를 string으로 명시
const d = first([1, 2, 3]); // T를 number로 추론
// 넘긴 값으로 알아내니 보통은 d처럼 생략한다
추론이 안 되거나 일부러 넓은 타입으로 고정하고 싶을 때만 <string>처럼 적어주면 된다. 평소엔 생략하는 게 읽기에도 낫다. 꺾쇠를 매번 쓰라는 문법이 아니라, 필요할 때 열어둔 비상구쯤으로 보면 된다.
타입 매개변수는 여러 개도 된다. 콤마로 <T, U>처럼 늘리면 서로 다른 타입 두 개를 각각 기억한다. 두 값을 짝으로 묶는 함수를 보자.
function pair<T, U>(a: T, b: U): [T, U] {
return [a, b];
}
const p = pair("age", 20);
// p의 타입: [string, number]
첫 값에서 T가 문자열, 둘째 값에서 U가 숫자로 각각 정해져, 반환 튜플이 [string, number]로 딱 맞게 나온다. 둘을 any로 뭉갰다면 나중에 p[0]이 문자열인지 숫자인지 알 길이 없었을 거다. 타입이 여럿 얽힐수록 제네릭이 이렇게 관계를 기억해준다는 게 크다.
함수뿐 아니라 타입에도 빈칸을 뚫는다. 제네릭은 함수만의 것이 아니다. 인터페이스나 타입 별칭 이름 뒤에 <T>를 붙이면, 무엇을 담는지는 쓸 때 정하는 "상자" 타입을 만들 수 있다.
interface Box<T> { value: T }
const b: Box<number> = { value: 3 }; // ok
const c: Box<string> = { value: 5 };
error TS2322: Type 'number' is not assignable to type 'string'.
Box<number>는 value가 숫자인 상자, Box<string>는 문자열인 상자다. c는 문자열 상자라 했는데 숫자를 넣어 걸렸다. 배열이 number[]처럼 원소 타입을 받는 것과 같은 발상이고, 실제로 우리가 쓰는 Array<T>, Promise<T>가 다 이렇게 생긴 제네릭 타입이다.
제약으로 T에 최소 조건을 건다. T가 "아무 타입"이라 좋기만 한 건 아니다. 아무 타입이니 그 값으로 할 수 있는 것도 없다. 길이를 재려고 .length를 부르면 이렇게 막힌다.
function firstLen<T>(x: T) {
return x.length; // 에러
}
// Property 'length' does not exist on type 'T'.
T가 무엇이 올지 모르니 length가 있으리란 보장이 없어 막는 거다. 이럴 때 extends로 "적어도 이런 모양은 갖춰라"라고 조건을 건다. 그래야 그 안에서 해당 속성을 쓸 수 있다.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("abc", "de"); // ok, 문자열엔 length가 있다
longest([1, 2, 3], [4]); // ok, 배열에도 있다
longest(10, 20); // 에러
// Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
T extends { length: number }는 "length를 가진 타입이면 뭐든 T로 받겠다"는 뜻이다. 문자열과 배열은 length가 있어 통과하지만, 숫자는 없어서 걸린다. 제약을 걸어도 T는 여전히 제네릭이라, 넣은 게 문자열이면 문자열이, 배열이면 배열이 그대로 반환된다. 자유는 최대한 열어두되 최소한의 약속만 받아내는, 제네릭을 실무에서 쓸 만하게 만드는 장치다.
제네릭은 결국 "타입을 위한 빈칸"이다. 함수가 값을 미리 모른 채 받아 처리하듯, 타입도 미리 정하지 않고 받아서 그대로 흘려보낸다. 필요하면 extends로 그 빈칸에 최소 조건만 걸어두면 된다. 복붙과 any 사이에서 고민하던 자리에 이 빈칸을 하나 뚫어주면, 함수 하나로 온갖 타입을 안전하게 상대할 수 있다. 남의 라이브러리 타입을 읽다 보면 <T>가 사방에 박혀 있는데, 그게 다 이 빈칸이다.