제네릭 함수, 컴파일러는 T를 어떻게 정하는가
T는 어디서 오는가
제네릭 함수를 처음 보면 <T>가 마법처럼 느껴집니다. 하지만 실제로 컴파일러가 하는 일은 단순합니다 — 호출부에서 넘긴 인수의 타입을 보고, 그 타입을 T 자리에 대입해보는 것뿐입니다. 이 과정을 "타입 인수 추론(type argument inference)"이라고 부릅니다.
function wrapInArray<T>(value: T): T[] {
return [value];
}
const a = wrapInArray(3); // T = number → number[]
const b = wrapInArray("hi"); // T = string → string[]
const c = wrapInArray<number>(3); // 명시적으로 지정할 수도 있다wrapInArray(3)을 호출하면 컴파일러는 매개변수 value의 타입이 T라는 걸 알고, 실제 인수 3의 타입인 number를 T에 대입합니다. 대부분의 경우 이렇게 자동으로 추론되기 때문에 <number>처럼 명시적으로 타입을 써줄 일은 드뭅니다. 명시가 필요한 순간은 뒤에서 다룹니다.
추론은 "어디서 T를 봤는가"에 따라 우선순위가 다르다
인수가 하나뿐이면 추론이 명확하지만, 타입 매개변수가 여러 위치에 나타나면 컴파일러는 후보들을 모아서 하나로 합쳐야 합니다. 이때 흔히 오해하는 부분이 있습니다.
function pickLarger<T>(a: T, b: T): T {
return a > b ? a : b;
}
const x = pickLarger(1, 2); // T = number
const y = pickLarger(1, "2"); // T = number | string ... 가 아니라 에러pickLarger(1, "2")는 왜 실패할까요? a와 b가 둘 다 같은 T로 선언되어 있으니, 컴파일러는 number와 string이라는 두 후보를 하나의 T로 통일해야 합니다. 이때 TypeScript는 두 후보의 공통 상위 타입을 찾으려 시도하는데, number와 string은 서로 관련이 없으니 통일할 수 없어 에러를 냅니다. 만약 T가 각 매개변수마다 독립적으로 선언되어 있었다면(pickLarger<A, B>(a: A, b: B)) 문제없이 통과했겠지만, 그러면 반환 타입이 A | B가 되어 "더 큰 값"이라는 의도와 맞지 않는 타입이 됩니다. 제네릭을 설계할 때 타입 매개변수를 하나로 묶을지 나눌지는 문법 선택이 아니라 "이 값들이 실제로 같은 타입이어야 하는가"를 반영하는 결정입니다.
문맥적 타입이 추론에 끼어드는 경우
인수 자체의 타입만으로 추론이 끝나는 게 아닙니다. 함수가 어디에 대입되는지, 즉 "문맥"도 추론에 영향을 줍니다.
function makeHandler<T>(fn: (value: T) => void): (value: T) => void {
return fn;
}
const handler: (value: number) => void = makeHandler((v) => {
console.log(v.toFixed(2)); // v는 number로 추론된다
});makeHandler에 넘긴 화살표 함수 (v) => ...만 보면 v의 타입을 알 방법이 없습니다. 하지만 결과를 (value: number) => void 타입의 변수에 대입하고 있기 때문에, 컴파일러는 이 문맥을 역으로 이용해 T가 number라고 추론합니다. 이런 방식을 "문맥적 타이핑(contextual typing)"이라고 하는데, React의 이벤트 핸들러나 콜백 기반 API에서 매개변수 타입을 따로 안 써도 자동완성이 뜨는 이유가 바로 이것입니다.
추론이 실패할 때: 명시적 타입 인수와 satisfies
추론이 원하는 결과를 내지 못하는 흔한 경우가 빈 배열입니다.
function toSet<T>(items: T[]): Set<T> {
return new Set(items);
}
const empty = toSet([]); // T가 unknown으로 추론되어 Set<unknown>
const nums = toSet<number>([]); // 명시적으로 지정빈 배열 []에는 원소가 없으니 컴파일러가 참고할 값이 없습니다. 이럴 때는 toSet<number>([])처럼 꺾쇠괄호로 타입 인수를 직접 지정해야 합니다. 실무에서는 이런 상황이 생각보다 자주 나옵니다 — 초기값이 빈 배열이나 빈 객체인 상태 관리 코드, 제네릭 팩토리 함수 등입니다.
TypeScript 4.9부터 등장한 satisfies는 추론 문제를 다른 각도에서 해결합니다. 값의 타입을 넓히지 않으면서 특정 타입을 만족하는지만 검사하고 싶을 때 씁니다.
const config = {
mode: "dark",
retries: 3,
} satisfies { mode: "dark" | "light"; retries: number };
config.mode; // "dark" | "light"가 아니라 "dark" 리터럴 타입 그대로 유지된다config에 타입 주석을 직접 달았다면 config.mode는 "dark" | "light"로 넓혀졌겠지만, satisfies는 "이 객체가 저 타입과 호환되는지만 확인하고, 실제 타입은 원래 추론된 리터럴 타입을 유지하라"고 지시합니다. 제네릭 함수에 리터럴 값을 넘길 때 타입이 불필요하게 넓혀지는 문제를 이 방식으로 피할 수 있습니다.
추론 순서를 정리하면: 인수의 실제 타입 → 문맥적 타입 → 그래도 안 되면 개발자가 명시. 이 순서를 알고 있으면 "왜 갑자기 unknown이 됐지"라는 상황을 마주쳤을 때 어디를 봐야 할지 감이 잡힙니다.