- Published on
TypeScript 5.5 타입 추론 깨짐 5가지 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀/패키지에서 TypeScript 5.5로 업그레이드한 뒤 “분명히 예전엔 되던 타입 추론이 깨졌다”는 이슈가 자주 올라옵니다. 실제로는 추론이 완전히 망가진 게 아니라, (1) 제네릭이 기본값으로 흘러가거나, (2) 오버로드 선택이 바뀌거나, (3) 객체 리터럴의 widen, (4) union 좁히기 실패, (5) 빌드 설정/타입 정의 불일치로 인해 결과가 더 넓은 타입으로 안전하게 후퇴하는 경우가 많습니다.
이 글은 TypeScript 5.5 환경에서 자주 만나는 “타입 추론 깨짐”을 5가지 패턴으로 분류하고, 각 패턴별로 **재현 코드 → 원인 → 해결책(우선순위)**를 제시합니다.
> 디버깅 관점에서의 공통 팁: 문제가 터진 지점만 보지 말고 타입이 넓어지기 시작한 첫 지점(대개 함수 경계/배열 리터럴/옵션 객체)에서 hover type과 tsc --noEmit 에러를 확인하세요. 에러가 없는데 추론이 이상하면 tsc --traceResolution로 타입 정의가 어떤 버전으로 잡혔는지도 같이 봐야 합니다.
1) 배열/튜플이 갑자기 (string | number)[]로 넓어짐
재현
// 기대: ["GET", "/users"] 같은 튜플
// 실제: (string)[] 혹은 (string | number)[] 로 widen
const route = ["GET", "/users"];
function register(method: "GET" | "POST", path: string) {}
register(route[0], route[1]); // route[0]이 string이면 에러/추론 붕괴
원인
배열 리터럴은 기본적으로 widen되기 쉽습니다. 특히 다른 연산(조건, spread, map)과 섞이면 “튜플로 유지”되지 않고 일반 배열로 추론되며, 그 순간부터 인덱스 접근 시 리터럴 타입이 사라집니다.
해결책 (우선순위)
as const로 리터럴/튜플 고정
const route = ["GET", "/users"] as const;
register(route[0], route[1]);
satisfies로 구조만 검증하고 값은 최대한 유지
type Route = readonly ["GET" | "POST", string];
const route = ["GET", "/users"] as const satisfies Route;
- 함수 경계에서 튜플을 명시
function makeRoute<const T extends readonly ["GET" | "POST", string]>(t: T) {
return t;
}
const route = makeRoute(["GET", "/users"] as const);
2) filter(Boolean) 이후 타입이 안 좁혀짐(또는 any로 새는 느낌)
재현
const xs = ["a", "", undefined, "b"];
const ys = xs.filter(Boolean);
// 기대: string[]
// 실제: (string | undefined)[] 또는 string[]로 안 좁혀짐
원인
Boolean은 타입 가드가 아니라 런타임 함수입니다. filter가 타입을 좁히려면 콜백이 value is ... 형태의 사용자 정의 타입 가드여야 합니다.
해결책
- 타입 가드 함수로 교체
function isTruthy<T>(v: T): v is Exclude<T, "" | 0 | false | null | undefined> {
return Boolean(v);
}
const ys = xs.filter(isTruthy); // string[]
- null/undefined만 제거하고 싶다면 더 구체적으로
function notNullish<T>(v: T): v is NonNullable<T> {
return v != null;
}
const zs = ["a", undefined, "b"].filter(notNullish); // string[]
- 임시방편(비추천): 단언
const ys = xs.filter(Boolean) as string[];
3) 옵션 객체를 합치면 제네릭이 unknown/any로 후퇴
재현
type Options<T> = {
parse?: (raw: string) => T;
};
function load<T>(key: string, opts: Options<T> = {}) {
return opts.parse?.("42");
}
const base = { parse: (s: string) => Number(s) };
const extra = { cache: true };
const v = load("age", { ...base, ...extra });
// 기대: number | undefined
// 실제: unknown | undefined 처럼 추론이 깨진 느낌
원인
스프레드로 객체를 합치는 순간 타입이 교차/확장되며, Options<T>의 제네릭 추론에 필요한 힌트가 약해지면 T가 기본값(없으면 unknown)으로 떨어집니다. 또한 “초과 속성 검사(excess property check)”가 스프레드로 인해 느슨해져, 잘못된 옵션이 섞여도 경고가 약해집니다.
해결책
satisfies로 옵션의 형태를 먼저 고정
const base = {
parse: (s: string) => Number(s),
} satisfies Options<number>;
const v = load("age", { ...base, cache: true });
- 제네릭을 호출부에서 명시(가장 확실)
const v = load<number>("age", { ...base, cache: true });
- 옵션 병합을 함수로 감싸서 추론 포인트를 분리
function withCache<T>(opts: Options<T>) {
return { ...opts, cache: true };
}
const v = load("age", withCache({ parse: (s) => Number(s) }));
4) 오버로드/유니온에서 분기 좁히기가 실패
재현
type Ok = { ok: true; data: string };
type Fail = { ok: false; error: Error };
type Result = Ok | Fail;
function handle(r: Result) {
if (r.ok) {
r.data; // 기대: string
} else {
r.error; // 기대: Error
}
}
이 케이스는 보통 잘 좁혀지지만, 실제 현장에서는 ok가 리터럴로 유지되지 않거나(예: boolean으로 widen), 중간 변환(map/spread/destructure)으로 인해 판별식이 깨져서 좁히기가 실패합니다.
흔한 깨짐 패턴
const r = { ok: true, data: "x" };
// ok가 true 리터럴이 아니라 boolean으로 widen되면 판별 유니온이 성립하지 않음
const r2 = { ...r }; // 스프레드/할당 과정에서 리터럴 유지가 깨질 수 있음
해결책
- 결과 객체를 만들 때
as const또는satisfies사용
const r = { ok: true, data: "x" } as const;
// 또는
const r = { ok: true, data: "x" } satisfies Ok;
- 판별식(discriminant)을 더 “안 깨지게” 설계
ok: true/false대신type: "ok" | "fail"같은 문자열 리터럴이 더 견고한 경우가 많습니다.
type Ok2 = { type: "ok"; data: string };
type Fail2 = { type: "fail"; error: Error };
type Result2 = Ok2 | Fail2;
- 함수 경계에서 유니온을 유지하도록 반환 타입을 명시
function makeOk(data: string): Ok {
return { ok: true, data };
}
5) “같은 코드인데” 패키지/빌드에서만 타입이 깨짐 (tsconfig·@types·ESM/CJS)
증상
- 로컬 에디터에서는 괜찮은데 CI에서만
any가 늘어남 - 모노레포에서 어떤 패키지만 제네릭 추론이 이상함
- 특정 라이브러리 타입이
any로 보이거나, 함수 오버로드가 엉뚱한 것으로 선택됨
원인 후보
tsconfig의moduleResolution,verbatimModuleSyntax,skipLibCheck,types설정 차이@types/*버전 불일치(의존성 트리에서 중복 설치)- ESM/CJS 타입 선언이 다른 엔트리를 가리킴(
exports조건부 타입)
빠른 진단 체크
# 실제 어떤 선언 파일이 잡히는지 확인
npx tsc --noEmit --traceResolution | grep -i "your-lib" -n
# 타입 버전 중복 확인
npm ls @types/node
npm ls typescript
해결책
- 모노레포라면 TypeScript 버전과
@types/node를 루트에서 고정
- pnpm/yarn berry는
overrides/resolutions로 강제하는 편이 안전합니다.
tsconfig를 패키지별로 쪼갰다면, 최소한 아래는 일관되게
{
"compilerOptions": {
"strict": true,
"moduleResolution": "Bundler",
"skipLibCheck": false
}
}
- 라이브러리 타입이 ESM/CJS 조건부로 갈라진다면 import 스타일을 통일
// 혼용 금지 예: require + esModuleInterop 조합 등
import { something } from "some-lib";
빌드/런타임 조건이 꼬여 “원인 파악이 오래 걸리는 문제”는 인프라/네트워크 이슈처럼 로그로 진단 루틴을 고정해두는 게 효과적입니다. 비슷한 접근으로 장애를 30분 내 좁히는 방법은 Cloudflare 520·521, Nginx·ALB 로그로 30분 진단도 참고할 만합니다.
실전 적용 순서: 가장 덜 침습적인 것부터
- 깨진 지점의 값에
as const/satisfies를 먼저 적용해 리터럴 유지 filter(Boolean)같은 패턴을 타입 가드로 교체- 객체 스프레드로 옵션을 합치는 코드는 추론 포인트를 함수로 분리하거나 제네릭을 명시
- 판별 유니온이 흔들리면 discriminant를 문자열 리터럴로 재설계
- 마지막으로
tsconfig/타입 패키지/모듈 해석을 traceResolution로 확정
부록: 추론이 깨졌을 때 바로 써먹는 “타입 스냅샷” 유틸
에디터 hover가 애매할 때, 타입을 강제로 펼쳐 확인하는 유틸을 하나 두면 빨라집니다.
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
type Debug<T> = Expand<T>;
// 사용 예
const xs = ["a", "", undefined, "b"];
type Ys = Debug<typeof xs>; // (string | undefined)[] 같은 실제 추론을 눈으로 확인
마무리
TypeScript 5.5에서 “타입 추론이 깨졌다”는 말은 대개 추론 힌트가 약해진 지점에서 타입이 widen/후퇴한 결과입니다. 위 5가지 패턴(튜플 widen, filter 타입 가드 부재, 스프레드로 인한 제네릭 추론 약화, 판별 유니온 깨짐, 빌드/타입 정의 불일치)만 먼저 점검해도 대부분의 케이스는 빠르게 복구됩니다.
업그레이드 후 CI에서만 문제가 재현된다면, 애플리케이션 로직보다도 해석되는 타입 선언 파일이 무엇인지부터 고정하세요. --traceResolution 한 번이 반나절 디버깅을 줄여줍니다.