- Published on
TypeScript 5.5 noImplicitAny 폭탄 - inferred type 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 인프라 장애처럼 보이진 않지만, TypeScript 5.5 업그레이드 직후 CI가 빨갛게 물드는 순간은 개발자에게는 거의 장애 대응과 비슷한 압박을 줍니다. 특히 noImplicitAny: true를 켜둔 프로젝트에서 “왜 전에는 되던 게 갑자기 implicitly has an 'any' type 류의 오류로 터지지?” 같은 상황을 자주 겪습니다. 이 글은 TypeScript 5.5에서 자주 체감되는 inferred type(추론 타입) 변화/강화로 인해 noImplicitAny가 연쇄적으로 폭발하는 패턴을 모아, 빠르게 원인을 좁히고 안전하게 고치는 방법을 정리합니다.
> 디버깅 접근은 네트워크 503, OIDC 토큰 오류 같은 운영 이슈와 크게 다르지 않습니다. “증상→재현→관측 포인트→가설→격리→최소 수정” 흐름으로 가야 합니다. (비슷한 문제 해결 사고방식은 GitHub Actions OIDC STS 실패 - InvalidIdentityToken 같은 글에서도 유효합니다.)
TypeScript 5.5에서 왜 갑자기 터지나
noImplicitAny는 “명시/추론 결과가 any로 흘러가는 지점”을 막습니다. 문제는 업그레이드 후 다음이 동시에 발생할 수 있다는 점입니다.
- 추론 경로가 바뀌어 예전엔 특정 타입으로 굳던 것이, 이제는 더 엄격한 조건에서 추론 실패 →
any/unknown/넓은 타입으로 바뀜 - 라이브러리(특히
@types/*) 업데이트로 제네릭 기본값/오버로드가 변경되어 기존 코드의 타입 유추가 깨짐 tsconfig의moduleResolution,verbatimModuleSyntax,isolatedModules등 주변 옵션 변화로 타입 정보가 사라지거나(특히 타입 전용 import/export) 다른 선언이 선택됨
이 글에서는 “TypeScript 5.5 자체 변화”를 특정 릴리즈 노트 한 줄로 단정하기보다, 실무에서 많이 보이는 폭발 패턴을 중심으로 디버깅 루트를 제공합니다.
폭발 패턴 1: 콜백 파라미터가 암묵적 any가 되는 경우
가장 흔한 형태는 “콜백의 인자 타입이 더 이상 문맥에서 추론되지 않음”입니다.
재현 예시: 제네릭이 깨지며 콜백 인자가 any로
// before: 어딘가에서 T가 잘 추론되어 cb의 x도 타입이 잡혔다고 가정
function pipe<T>(value: T, cb: (x: T) => T) {
return cb(value);
}
// 문제: 아래처럼 타입 정보가 사라지면 cb의 인자에 any가 스며들 수 있음
const out = pipe(JSON.parse('{"a":1}'), (x) => {
// x가 any로 추론되면 noImplicitAny에서 걸리거나,
// x.a 접근이 무제한 허용되는 등 안전성이 깨짐
return x;
});
JSON.parse는 기본적으로 any를 반환합니다(표준 라이브러리 타입 정의). TypeScript 버전이 올라가며 주변 추론이 조금만 바뀌어도, 이 any가 파이프라인 전체로 전염됩니다.
해결 1) unknown으로 차단 후 좁히기
const raw: unknown = JSON.parse('{"a":1}');
function isObj(v: unknown): v is { a: number } {
return typeof v === "object" && v !== null && "a" in v;
}
if (!isObj(raw)) throw new Error("invalid");
const out = pipe(raw, (x) => ({ a: x.a }));
해결 2) 제네릭을 명시해 추론 경로 고정
type Payload = { a: number };
const out = pipe<Payload>(JSON.parse('{"a":1}') as Payload, (x) => x);
> as Payload는 런타임 검증이 없으니, 가능하면 unknown+가드로 막는 편이 장기적으로 안전합니다.
폭발 패턴 2: Array.prototype.map/filter/reduce에서 콜백이 any로 붕괴
배열 메서드의 콜백은 원래 “배열 원소 타입”에서 인자 타입이 정해져야 합니다. 그런데 배열이 any[]가 되면 콜백 인자도 any가 됩니다.
재현 예시: any[]의 기원 찾기
const rows = JSON.parse('[{"id":1},{"id":2}]'); // any
const ids = rows.map((r) => r.id); // r: any
이런 코드는 버전과 무관하게 위험하지만, TypeScript 5.5로 올리면서 주변 코드가 조금 바뀌어 rows가 더 자주 any로 남게 되면 CI에서 폭발합니다.
해결: satisfies로 형태를 고정하고 추론을 보존
type Row = { id: number };
const rows = JSON.parse('[{"id":1},{"id":2}]') as unknown;
const typedRows = (rows as Row[]) satisfies Row[];
const ids = typedRows.map((r) => r.id);
satisfies는 “타입을 강제 캐스팅”하기보다 “이 값이 해당 타입 조건을 만족하는지”를 체크해 추론을 유지하는 데 유리합니다.
폭발 패턴 3: 인덱스 접근/동적 키에서 any가 새어 나오는 경우
동적 키를 쓰는 순간, 타입 시스템은 보수적으로 넓어집니다. 5.5에서 타입 추론이 더 엄격해지거나(혹은 더 정확해지며) 기존의 “운 좋게 통과하던” 코드가 깨질 수 있습니다.
재현 예시: obj[key]가 any/unknown으로
const obj = { a: 1, b: 2 };
function get(key: string) {
return obj[key];
// key: string 이면 obj[string]은 number가 아니라
// (정확히는) 인덱스 시그니처가 없어서 에러/any 유발
}
해결: 키를 keyof로 제한
const obj = { a: 1, b: 2 };
type Key = keyof typeof obj; // "a" | "b"
function get(key: Key) {
return obj[key]; // number
}
해결: 레코드/인덱스 시그니처로 의도를 표현
const dict: Record<string, number> = { a: 1, b: 2 };
function get(key: string) {
return dict[key]; // number
}
폭발 패턴 4: 오버로드/유니온에서 콜백 문맥 타입이 사라지는 경우
라이브러리 함수가 오버로드를 많이 갖고 있으면, 호출 시그니처 선택이 미묘하게 바뀌면서 콜백 타입이 any로 떨어지는 일이 있습니다.
재현 예시: 시그니처 선택 실패
declare function on(event: "data", cb: (x: { id: string }) => void): void;
declare function on(event: string, cb: (x: any) => void): void;
on("data", (x) => {
// 기대: x.id
// 실제: 특정 조건에서 두 번째 오버로드로 매칭되면 x: any
console.log(x.id);
});
해결: 리터럴 타입 유지(상수화)로 올바른 오버로드 선택
const event = "data" as const;
on(event, (x) => {
console.log(x.id);
});
또는 호출부에서 제네릭/타입 인자를 명시해 시그니처 선택을 고정합니다.
폭발 패턴 5: tsconfig/빌드 파이프라인 변화로 타입이 증발
TypeScript 업그레이드와 함께 다음이 같이 바뀌면 “추론 타입이 갑자기 any”가 될 수 있습니다.
skipLibCheck를 끄면서 라이브러리 타입 충돌이 드러남moduleResolution변경으로 다른.d.ts가 선택됨types/typeRoots설정으로 전역 타입이 빠짐isolatedModules/verbatimModuleSyntax로 타입 전용 import/export 누락이 오류로 승격
체크리스트
// tsconfig.json
{
"compilerOptions": {
"noImplicitAny": true,
"strict": true,
"skipLibCheck": false,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"isolatedModules": true
}
}
여기서 중요한 건 “옵션을 끄자”가 아니라, 어떤 옵션이 타입 정보 흐름을 바꿨는지를 파악하는 것입니다.
디버깅 실전: inferred type 오류를 빨리 좁히는 방법
1) 에러 지점의 ‘any의 근원’을 역추적
noImplicitAny가 터진 위치는 보통 “전염의 끝”입니다. 진짜 원인은 그 위(입력)에서 any가 만들어진 지점입니다.
JSON.parse,req.body(Express),process.env,axios.get의 응답 타입 미지정as any가 남아 있는 유틸Object.entries/keys/values를 무심코 쓴 뒤 타입이 넓어짐
2) tsc --noEmit --pretty false로 CI 로그를 안정적으로 수집
npx tsc --noEmit --pretty false
로그를 텍스트 기반으로 고정하면, PR에서 에러 diff를 비교하기 쉬워집니다.
3) “타입을 출력”해서 추론 결과를 눈으로 확인
런타임 출력이 아니라 컴파일 타임에서 타입을 강제로 확인하는 패턴이 유용합니다.
// 타입 동등성 체크 유틸 (컴파일 타임)
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2)
? true
: false;
type Expect<T extends true> = T;
type T1 = ReturnType<typeof JSON.parse>; // any
// 아래는 의도적으로 실패시켜 "어디서 any가 되었는지"를 드러내는 식으로 사용
// type _check = Expect<Equal<T1, unknown>>;
또는 VS Code의 “Go to Type Definition / Hover”로도 충분히 원인을 찾을 수 있습니다.
4) 해결은 ‘캐스팅’보다 ‘차단’과 ‘좁히기’ 우선
any를 발견하면 unknown으로 바꾸고- 타입 가드/스키마 검증(Zod, Valibot 등)으로 좁히기
- 마지막 수단으로만
as SomeType캐스팅
이 전략은 장애 대응에서 “재시도 남발” 대신 “원인(레이트리밋 헤더) 관측 후 설계”로 가는 것과 같습니다. 비슷한 관점의 글로 OpenAI 429와 Rate Limit 헤더로 재시도 설계도 참고할 만합니다.
안전한 마이그레이션 전략: noImplicitAny 폭탄을 ‘분할 정복’하기
한 번에 전부 고치려 하면 PR이 커지고 리뷰가 불가능해집니다.
1) 에러를 카테고리로 나누기
- 입력 경계(any 유입):
JSON.parse, HTTP 요청/응답, env - 컬렉션 처리(map/filter/reduce)에서의 any 전염
- 동적 키/리플렉션(Object.*)에서 타입 손실
- 라이브러리 오버로드 선택 실패
2) 입력 경계부터 고치면 하류 에러가 같이 사라짐
예를 들어 req.body를 any로 두면 라우터/서비스/DB 레이어까지 전염됩니다. 반대로 경계에서 unknown+검증으로 잡으면 하류의 noImplicitAny 에러가 눈에 띄게 줄어듭니다.
3) 임시 봉합이 필요하면 “최소 범위”로
정말 급하면 아래처럼 국소적으로만 any를 허용하되, TODO를 남기고 범위를 제한합니다.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unsafeParse(s: string): any {
return JSON.parse(s);
}
// 이후 호출부에서 반드시 unknown으로 받게 강제
const raw: unknown = unsafeParse("...");
자주 묻는 케이스 Q&A
inferred type 관련 오류가 특정 파일에서만 폭발한다
- 해당 파일이 빌드 타겟/모듈 시스템 영향을 더 많이 받는지 확인하세요(예: ESM 전환 중인 패키지).
exports조건부 엔트리 때문에 다른.d.ts가 잡히는 경우도 있습니다.
“예전엔 추론되던 제네릭이 이제는 any가 된다”
- 호출부에서 제네릭 인자를 명시해 “추론 경로”를 고정하세요.
- 콜백 파라미터에 타입을 직접 적어 문맥 의존을 줄이세요.
someFn((x: MyType) => {
// ...
});
결론: TypeScript 5.5의 noImplicitAny 폭탄은 ‘전염원’부터 제거하자
TypeScript 5.5 업그레이드 후 noImplicitAny가 폭발하는 느낌을 받는 이유는, 대부분 “갑자기 더 엄격해져서”라기보다 기존에 숨어 있던 any 전염원이 더 잘 드러나거나, 추론 경로가 바뀌며 전염이 확대되기 때문입니다. 해결의 핵심은 다음 3가지입니다.
- 에러 지점이 아니라 any가 생성된 입력 경계를 찾는다.
any는unknown으로 차단하고, 타입 가드/스키마로 좁힌다.- 오버로드/제네릭은 리터럴 유지(as const) 또는 제네릭 명시로 추론을 안정화한다.
업그레이드로 CI가 깨졌다면, 이 과정을 체크리스트처럼 반복하면서 “폭탄”을 “정리된 타입 부채”로 바꿔보세요.