- Published on
TS 5.5+ 타입 추론 함정 7가지와 해결 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
타입스크립트 5.5+로 올라오면 “이전엔 잘 되던 추론”이 미묘하게 달라지거나, 반대로 “추론이 너무 똑똑해져서” 의도치 않은 타입으로 굳어지는 경우가 있습니다. 특히 라이브러리 코드나 공용 유틸을 만들 때 이런 차이가 런타임 버그로 이어지기 쉽습니다.
이 글은 TS 5.5+ 환경에서 실무에서 자주 터지는 타입 추론 함정 7가지를, 재현 코드와 함께 고정적으로 통하는 해결 패턴으로 정리합니다.
관련해서 타입 가드 is로 narrowing이 기대대로 안 되는 케이스는 별도 글에서 더 깊게 다뤘습니다: TS 5.5에서 is로 narrowing 안 될 때 7가지
1) 배열 리터럴이 string[]로 넓어져 유니온이 사라짐
가장 흔한 함정입니다. 라우트, 이벤트 이름, 권한 스코프처럼 “문자열 리터럴 집합”을 만들었는데, 추론이 string[]로 넓어지면 이후 모든 타입 안전성이 무너집니다.
const roles = ["admin", "member", "guest"]; // string[] 로 추론될 수 있음
type Role = (typeof roles)[number]; // string
해결 패턴 A: as const로 리터럴 고정
const roles = ["admin", "member", "guest"] as const;
type Role = (typeof roles)[number]; // "admin" | "member" | "guest"
해결 패턴 B: satisfies로 형태 검증 + 리터럴 유지
as const는 읽기 전용이 되기 때문에, “리터럴 유지”와 “형태 검증”을 분리하고 싶다면 satisfies가 좋습니다.
const roles = ["admin", "member", "guest"] as const satisfies readonly string[];
type Role = (typeof roles)[number];
핵심은 satisfies가 타입을 바꾸지 않고 검증만 한다는 점입니다.
2) 객체 리터럴의 값이 넓어져 키 기반 추론이 깨짐
맵 객체를 만들고 keyof로 키를 뽑거나, 값 타입을 인덱싱해 사용하려고 할 때 자주 발생합니다.
const statusText = {
ok: "OK",
fail: "FAIL",
};
type Status = keyof typeof statusText; // "ok" | "fail" (여기까진 OK)
type Text = (typeof statusText)[Status]; // string 로 넓어질 수 있음
해결 패턴: 값도 리터럴로 고정 (as const) 또는 satisfies
const statusText = {
ok: "OK",
fail: "FAIL",
} as const;
type Status = keyof typeof statusText; // "ok" | "fail"
type Text = (typeof statusText)[Status]; // "OK" | "FAIL"
혹은 “값은 리터럴로 유지하되, 구조는 특정 레코드를 만족해야 함”이라면:
const statusText = {
ok: "OK",
fail: "FAIL",
} as const satisfies Record<string, string>;
3) Object.keys / Object.entries가 키를 string으로 만들어버림
TS는 런타임에서 키가 실제로는 문자열로 나오기 때문에, 기본적으로 Object.keys 결과를 string[]로 잡습니다. 그 결과 안전한 인덱싱이 깨집니다.
const config = {
host: "localhost",
port: 5432,
} as const;
for (const k of Object.keys(config)) {
// k: string
// config[k] 는 에러 또는 any 유도
}
해결 패턴 A: 키 배열을 별도로 선언해 연결
const config = {
host: "localhost",
port: 5432,
} as const;
const configKeys = ["host", "port"] as const;
type ConfigKey = (typeof configKeys)[number];
for (const k of configKeys) {
const v = config[k];
// v: "localhost" | 5432
}
해결 패턴 B: 제네릭 헬퍼로 안전한 keys 만들기
function typedKeys<T extends object>(obj: T) {
return Object.keys(obj) as Array<keyof T>;
}
const config = { host: "localhost", port: 5432 } as const;
for (const k of typedKeys(config)) {
const v = config[k];
}
주의: 위 캐스팅은 “런타임 키가 실제로 keyof T를 벗어나지 않는다”는 전제를 둡니다. 외부 입력으로 만든 객체라면 검증 로직이 필요합니다.
4) filter/find에서 narrowing이 기대대로 안 됨
배열에서 특정 타입만 걸러내고 싶을 때, 조건식이 있어도 결과 타입이 안 좁혀지는 경우가 있습니다.
type Item = { kind: "a"; a: number } | { kind: "b"; b: string };
const items: Item[] = [
{ kind: "a", a: 1 },
{ kind: "b", b: "x" },
];
const onlyA = items.filter((x) => x.kind === "a");
// onlyA: Item[] 로 남을 수 있음
해결 패턴 A: 타입 가드 함수로 분리
function isA(x: Item): x is Extract<Item, { kind: "a" }> {
return x.kind === "a";
}
const onlyA = items.filter(isA);
// Extract<Item, { kind: "a" }>[]
해결 패턴 B: satisfies로 콜백 시그니처를 강제
const isA2 = ((x: Item) => x.kind === "a") satisfies (
(x: Item) => x is Extract<Item, { kind: "a" }>
);
const onlyA2 = items.filter(isA2);
is 기반 narrowing이 막히는 원인은 케이스가 다양합니다. 디버깅 포인트는 위 내부 링크 글을 참고하세요.
5) 제네릭 기본값/제약 때문에 추론이 unknown 또는 과도하게 넓어짐
유틸을 만들 때 “아무거나 받는 제네릭”을 의도했는데, 호출부에서 추론이 실패해 unknown으로 굳거나, 반대로 너무 넓어져 안전성이 떨어지는 경우가 있습니다.
function parseJson<T = unknown>(s: string): T {
return JSON.parse(s);
}
const v = parseJson("{\"x\":1}");
// v: unknown
이건 의도한 설계일 수도 있지만, 호출부에서 매번 제네릭을 주기 귀찮아 any 캐스팅으로 흐르기 쉽습니다.
해결 패턴 A: 런타임 검증기(스키마)와 결합
type User = { id: string; name: string };
function parseJsonWithGuard<T>(s: string, guard: (x: unknown) => x is T): T {
const v: unknown = JSON.parse(s);
if (!guard(v)) throw new Error("Invalid JSON shape");
return v;
}
const isUser = (x: unknown): x is User => {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
return typeof o.id === "string" && typeof o.name === "string";
};
const user = parseJsonWithGuard("{\"id\":\"1\",\"name\":\"A\"}", isUser);
해결 패턴 B: “추론 가능한 입력”을 시그니처에 포함
입력에 T가 등장하지 않으면(예: s: string만 있음) TS는 T를 추론할 근거가 없습니다. 가능하면 T를 유도할 힌트를 API에 넣습니다.
예: 키 배열을 받게 하거나, 디폴트 객체를 받게 하는 방식 등.
6) 함수 오버로드 없이 조건부 반환을 만들면 반환 타입이 무너짐
런타임 분기(옵션에 따라 반환 형태가 달라짐)를 한 함수로 만들면, 호출부에서 반환 타입이 유니온으로 뭉개져 사용성이 급격히 떨어집니다.
function getEnv(name: string, opts?: { required?: boolean }) {
const v = process.env[name];
if (opts?.required && !v) throw new Error("Missing");
return v; // string | undefined
}
const a = getEnv("TOKEN", { required: true });
// a: string | undefined 로 남을 수 있음
해결 패턴: 오버로드로 호출 시그니처를 분리
function getEnv(name: string, opts: { required: true }): string;
function getEnv(name: string, opts?: { required?: false }): string | undefined;
function getEnv(name: string, opts?: { required?: boolean }) {
const v = process.env[name];
if (opts?.required && !v) throw new Error("Missing");
return v;
}
const a = getEnv("TOKEN", { required: true });
// a: string
const b = getEnv("MAYBE");
// b: string | undefined
오버로드는 “구현부는 하나, 타입 시그니처는 여러 개”로 호출부 DX를 크게 올려줍니다.
7) Promise.all과 튜플 추론이 깨져 결과가 (T | U)[]가 됨
Promise.all은 튜플을 넣으면 튜플로 결과를 돌려주는 게 이상적입니다. 그런데 입력이 배열로 넓어지면 결과도 배열 유니온으로 뭉개집니다.
const p1 = Promise.resolve(1);
const p2 = Promise.resolve("x");
const r = await Promise.all([p1, p2]);
// r: (string | number)[] 로 추론될 수 있음
해결 패턴 A: 튜플로 고정 (as const)
const r = await Promise.all([p1, p2] as const);
// r: readonly [number, string]
해결 패턴 B: 변수로 빼면서 타입이 넓어지는지 점검
다음처럼 중간 변수로 빼면 추론이 더 쉽게 넓어질 수 있습니다.
const arr = [p1, p2];
const r2 = await Promise.all(arr);
이 경우는 arr를 튜플로 선언해 해결합니다.
const arr: [Promise<number>, Promise<string>] = [p1, p2];
const r2 = await Promise.all(arr);
// [number, string]
정리: TS 5.5+에서 “추론을 믿되, 고정 장치를 둬라”
위 7가지는 공통적으로 추론이 넓어지는 지점(리터럴이 string으로, 튜플이 배열로, 키가 string으로) 또는 추론 근거가 부족한 지점(입력에 제네릭이 등장하지 않음, 런타임 분기가 타입에 반영되지 않음)에서 발생합니다.
실무에서 추천하는 체크리스트는 다음과 같습니다.
- 리터럴 집합은
as const또는satisfies로 고정 Object.keys/entries는 typed helper 또는 키 배열을 별도 선언filter/find는 타입 가드 함수로 분리- 반환 타입이 옵션에 따라 달라지면 오버로드로 시그니처를 분리
Promise.all입력이 튜플인지(넓어지지 않았는지) 확인
추론 문제는 “타입 시스템이 틀렸다”기보다, API 설계가 추론 친화적이지 않은 경우가 많습니다. 특히 공용 유틸/SDK를 만드는 팀이라면, 위 패턴들(리터럴 고정, satisfies, 오버로드, 타입 가드)을 템플릿처럼 가져가면 TS 버전이 올라가도 안정적으로 유지보수할 수 있습니다.
추가로, 타입 가드와 narrowing이 엮인 케이스를 더 깊게 파고 싶다면 다음 글을 함께 보세요: TS 5.5에서 is로 narrowing 안 될 때 7가지