Published on

TS 5.5+ 타입 추론 함정 7가지와 해결 패턴

Authors

타입스크립트 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가지