- Published on
TS 5.5+ 제네릭 추론 깨질 때 실전 fixes
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
TypeScript를 5.5 이상으로 올린 뒤, 기존에 잘 추론되던 제네릭이 unknown 으로 떨어지거나, 오버로드 선택이 바뀌거나, 콜백 인자 타입이 넓어져서 연쇄적으로 오류가 나는 경우가 있습니다. 대부분은 “TS가 똑똑해졌다”기보다 추론 후보가 많아졌거나(복잡도 증가), 추론 경로가 바뀌었거나(버전별 미세 변화), 타입 폭발을 막기 위해 보수적으로 수렴하면서 생깁니다.
이 글은 업그레이드 직후 실무에서 자주 마주치는 패턴을 중심으로, 재현 코드와 함께 가장 비용이 낮은 순서대로 fixes를 정리합니다. 타입 폭발/추론 비용을 줄이는 쪽 배경은 TypeScript 5.5 infer로 타입 폭발 줄이는 법도 같이 보면 원인 파악이 빨라집니다.
먼저 확인할 것: “추론이 깨진 것”의 형태 분류
증상은 다양하지만, 대개 아래 중 하나로 귀결됩니다.
- 제네릭
T가unknown또는{}로 수렴 - 유니온이 과하게 넓어져서 분기 좁히기가 실패
- 오버로드가 다른 시그니처로 선택
- 콜백 인자(특히 이벤트/맵/필터)의 타입이
any또는 넓은 타입으로 변함 - 조건부 타입/재귀 타입에서 “타입 인스턴스화가 너무 깊다” 류 에러로 추론이 중단
이제 원인별 fixes를 보겠습니다.
Fix 1: “추론 앵커”를 하나 더 준다 (가장 저렴)
TS는 인자들로부터 T 를 추론합니다. 그런데 인자 중 하나라도 any 이거나 너무 넓은 타입이면, 전체 추론이 흔들립니다. 이때는 추론이 붙잡을 수 있는 앵커(명시 타입) 를 한 군데만 추가해도 복구되는 경우가 많습니다.
케이스: 콜백 기반 유틸에서 T 가 unknown 으로 떨어짐
function mapValues<T, R>(arr: T[], fn: (v: T) => R): R[] {
return arr.map(fn);
}
const xs = JSON.parse('[1,2,3]');
// TS 5.5+에서 xs가 any/unknown 취급이면 T 추론이 흔들릴 수 있음
const ys = mapValues(xs, (n) => n + 1);
Fix
JSON.parse결과를 구체화하거나mapValues호출 시 한 번만 타입을 고정
const xs = JSON.parse('[1,2,3]') as number[];
const ys = mapValues(xs, (n) => n + 1);
// 또는
const ys2 = mapValues<number, number>(xs as number[], (n) => n + 1);
포인트는 “모든 곳에 타입을 박는 것”이 아니라, 추론이 실패하는 지점 바로 앞에서 1회만 고정하는 겁니다.
Fix 2: satisfies 로 추론을 보존하면서 검증만 한다
업그레이드 후 흔한 문제는 “타입을 맞추려고 as 를 늘렸더니, 추론 정보가 사라져서 더 망가지는” 패턴입니다. as 는 강제 단언이라 이후 추론에 필요한 정보가 잘려나갈 수 있습니다.
이때 satisfies 는 값의 구체 타입을 유지하면서도, “이 타입을 만족해야 한다”는 검증만 걸 수 있어 안전합니다.
type Route = {
path: string;
method: "GET" | "POST";
};
const routes = [
{ path: "/health", method: "GET" },
{ path: "/login", method: "POST" },
] satisfies Route[];
// routes의 각 원소는 리터럴 정보를 유지(예: method가 "GET" 으로 남음)
제네릭 함수가 리터럴 기반 추론(예: 키 유니온, 태그드 유니온)을 기대한다면, satisfies 를 쓰는 것만으로도 TS 5.5+에서 바뀐 추론 흐름에 덜 흔들립니다.
Fix 3: “제네릭 위치”를 바꿔서 추론 방향을 단순화
TS의 추론은 함수 시그니처에서 제네릭이 어디에 등장하느냐에 영향을 받습니다. 특히 T 가 반환 타입 쪽에만 있거나, 조건부 타입 안쪽 깊숙이만 있으면 추론이 약해집니다.
케이스: 반환 타입에서만 T 가 등장
function makeBox<T>() {
return { value: null as unknown as T };
}
const b = makeBox();
// T를 추론할 근거가 없어서 unknown으로 굳거나, 호출부에서 매번 지정 필요
Fix: T 를 인자로 노출
function makeBox<T>(value: T) {
return { value };
}
const b = makeBox(123); // T = number
실무에서는 “팩토리 함수”나 “builder 패턴”에서 이 문제가 자주 터집니다. TS 5.5+에서 타입 폭발을 피하려고 보수적으로 수렴하는 상황이라면, 추론 근거를 입력으로 옮기는 리팩터링이 가장 확실합니다.
Fix 4: 오버로드가 바뀌었다면, 오버로드를 줄이거나 분리한다
TS 업그레이드 후 “같은 호출인데 다른 오버로드가 선택”되는 경우가 있습니다. 오버로드는 추론의 후보를 늘려서, 버전별 미세 변경에 영향을 크게 받습니다.
케이스: 넓은 시그니처가 먼저 먹어버림
function get(key: string): string;
function get(key: string, fallback: number): number;
function get(key: string, fallback?: unknown) {
// ...
return fallback ?? "";
}
const v = get("a", 0);
// 기대: number
// 실제: string으로 잡히거나, 구현 시그니처로 흘러가며 이상해지는 케이스가 생김
Fix A: 오버로드를 “구별 가능한 형태”로 만든다
function get(key: string): string;
function get(key: string, fallback: number): number;
function get(key: string, fallback?: number) {
return (fallback ?? "") as any;
}
Fix B: 애초에 API를 분리
const getString = (key: string) => key;
const getNumber = (key: string, fallback: number) => fallback;
오버로드를 유지해야 한다면, 각 시그니처가 명확히 구분되는 인자 타입을 갖도록 조정하세요. unknown 같은 범용 타입을 구현 시그니처에 넣어두면, 추론 경로가 흔들릴 여지가 커집니다.
Fix 5: “유니온 폭발”은 분배 조건부 타입을 멈춰라
TS 5.5+에서 체감상 많이 보이는 건, 조합이 커졌을 때 추론이 늦어지거나 never/unknown 으로 망가지는 상황입니다. 특히 조건부 타입이 유니온에 대해 분배(distribute)되면, 타입 연산량이 급증합니다.
케이스: 조건부 타입이 유니온에 분배되며 폭발
type Elem<T> = T extends (infer U)[] ? U : T;
type X = Elem<string[] | number[] | boolean[]>;
// U가 각 유니온 원소에 대해 따로 계산되며 복잡해질 수 있음
Fix: 분배를 막는 래핑
type Elem<T> = [T] extends [(infer U)[]] ? U : T;
type X = Elem<string[] | number[] | boolean[]>; // (string | number | boolean)
[T] extends [...] 형태는 “분배 조건부 타입”을 멈추는 대표 패턴입니다. 이 한 줄로 추론이 다시 안정화되는 경우가 많습니다.
Fix 6: 제네릭 기본값과 제약(extends)을 재점검한다
업그레이드 후 T = unknown 같은 기본값이 의도치 않게 활성화되거나, 제약이 너무 넓어서 추론이 느슨해지는 경우가 있습니다.
케이스: 기본값이 너무 넓어 downstream 추론을 망침
type ApiResult<T = unknown> = { ok: true; data: T } | { ok: false; error: string };
function unwrap<T>(r: ApiResult<T>) {
if (!r.ok) throw new Error(r.error);
return r.data;
}
const r: ApiResult = { ok: true, data: 123 };
const v = unwrap(r); // v가 unknown으로 남을 수 있음
Fix
- 호출부에서
ApiResult<number>로 좁히거나 - 생성 함수에서 제네릭을 잡아주기
function ok<T>(data: T): ApiResult<T> {
return { ok: true, data };
}
const r = ok(123);
const v = unwrap(r); // number
기본값은 “편의”지만, 타입 정보가 없는 값(예: 외부 입력, 파싱 결과)과 결합되면 추론을 무너뜨릴 수 있습니다.
Fix 7: 콜백 매개변수의 “컨텍스트 타입”이 사라졌다면, 함수 오브젝트를 분리 선언
TS는 인라인 화살표 함수에 컨텍스트 타입을 잘 부여하지만, 복잡한 제네릭/오버로드/조건부 타입과 얽히면 컨텍스트 부여가 실패할 수 있습니다. 이때는 콜백을 별도 상수로 분리하면 추론이 안정되는 경우가 있습니다.
type Handler<T> = (value: T) => void;
function on<T>(handler: Handler<T>) {
// ...
}
on((v) => {
// v가 unknown으로 보이는 케이스가 발생할 수 있음
});
const handler: Handler<number> = (v) => {
v.toFixed(2);
};
on(handler);
이 패턴은 “인라인으로 넣으면 깨지는데, 분리하면 된다”는 식으로 자주 나타납니다. 원인은 대개 컨텍스트 타입 제공 시점과 추론 시점이 꼬이는 것입니다.
Fix 8: noUncheckedIndexedAccess 나 exactOptionalPropertyTypes 변화가 원인인지 분리해서 본다
TS 5.5+로 올리면서 tsconfig 를 함께 강화하는 경우가 많습니다. 이때 “제네릭 추론이 깨졌다”고 느끼지만 실제론 아래 옵션의 영향일 수 있습니다.
noUncheckedIndexedAccess: 인덱싱 결과가T | undefined로 바뀌어 연쇄적으로 추론이 넓어짐exactOptionalPropertyTypes:prop?: T의 의미가 엄격해져,undefined처리 방식이 바뀜
케이스: 인덱싱이 undefined 를 섞어버림
// tsconfig: noUncheckedIndexedAccess: true
function pick<T extends object, K extends keyof T>(obj: T, key: K) {
return obj[key];
}
const o = { a: 1 };
const v = pick(o, "a");
// v가 number | undefined로 바뀌며 downstream 제네릭이 흔들릴 수 있음
Fix
- 호출부에서 존재를 보장하거나
- API를
getOrThrow형태로 분리
function getOrThrow<T extends object, K extends keyof T>(obj: T, key: K): T[K] {
const v = obj[key];
if (v === undefined) throw new Error("missing");
return v;
}
이건 “추론 버그”가 아니라 “정확도가 올라간 결과”인 경우가 많습니다.
Fix 9: 라이브러리 타입이 TS 5.5+와 충돌하면, typesVersions 또는 의존성 동기화
업그레이드 직후 깨지는 추론의 상당수는 내 코드보다 의존 라이브러리의 타입 정의에서 시작합니다.
- 오래된
@types/*가 최신 TS의 내장 타입과 충돌 - 라이브러리 본체는 최신인데 타입 패키지가 구버전
- 반대로 타입 정의는 최신인데 런타임 버전이 낮아 시그니처가 불일치
체크리스트
typescript와@types/node버전을 함께 올렸는지- 프레임워크(React, Next.js) 권장 TS 버전과 맞는지
- monorepo라면 패키지별 TS 버전이 섞이지 않는지
이 단계는 “코드 수정”이 아니라 “환경 동기화”로 해결되는 경우가 많아, 먼저 보는 게 좋습니다.
디버깅 루틴: 어디서부터 추론이 망가졌는지 빠르게 찾기
실무에서는 원인을 한 번에 맞히기 어렵습니다. 아래 순서로 최소 비용으로 범위를 줄이면 빠릅니다.
- 문제가 된 표현식을 변수로 쪼개기
- 중간 변수에 타입을 드러내면, 추론이 깨지는 최초 지점을 찾기 쉽습니다.
const step1 = buildQuery(params);
const step2 = execute(step1);
const step3 = normalize(step2);
해당 지점에만 “추론 앵커” 추가
as SomeType보다는satisfies또는 제네릭 인자 명시를 우선
조건부 타입은 분배를 막아보기
[T] extends [...]패턴 적용
오버로드는 후보를 줄이기
- 넓은 시그니처가 섞여 있으면 특히 의심
타입 추론이 복잡해져서 컴파일 성능까지 나빠졌다면, 앞서 언급한 타입 폭발 대응 글(TypeScript 5.5 infer로 타입 폭발 줄이는 법)의 “계산량 줄이기” 섹션이 직접적인 처방이 됩니다.
마무리: 가장 덜 침습적인 fix부터 적용하자
TS 5.5+에서 제네릭 추론이 깨질 때, 무작정 as any 로 덮으면 단기적으로는 조용해지지만 장기적으로 타입 안전성과 리팩터링 내성이 무너집니다. 추천 우선순위는 아래입니다.
- 호출부에 추론 앵커 1회 추가
satisfies로 검증만 걸고 리터럴 추론 유지- 제네릭이 반환에만 있으면 입력으로 이동
- 오버로드는 구별 가능하게 정리 또는 분리
- 조건부 타입은 분배 중단으로 폭발 방지
tsconfig옵션 변화가 원인인지 분리
이 순서대로만 정리해도, “업그레이드 후 갑자기 깨진” 문제의 대부분은 코드 전체를 갈아엎지 않고 해결됩니다.