Published on

TS 5.5+ 타입 추론 깨짐? satisfies로 고치기

Authors

서버나 프런트엔드에서 TypeScript 버전을 올리면, 런타임은 그대로인데도 타입 에러가 갑자기 늘거나 자동완성이 약해지는 경험이 있습니다. 특히 TS 5.5+로 올라오면서 "내가 의도했던 추론"과 "컴파일러가 선택한 추론"이 엇갈리는 케이스가 눈에 띄게 늘었습니다.

이 글은 그 현상을 “TS가 망가졌다”로 끝내지 않고, 왜 이런 일이 생기는지, 그리고 satisfies로 어떻게 안정적으로 고칠 수 있는지에 집중합니다. 결론부터 말하면, as SomeType으로 억지로 캐스팅하는 대신 satisfies를 쓰면 검증은 강하게, 추론은 좁게 가져갈 수 있습니다.

TS 5.5+에서 "추론이 깨졌다"고 느끼는 대표 증상

아래 같은 증상이 TS 업그레이드 이후 자주 등장합니다.

  • 객체 리터럴을 타입에 맞춘다고 as를 붙였더니, 오히려 프로퍼티 타입이 넓어져서(예: 리터럴이 string으로) 분기 로직이 약해짐
  • Record<string, ...> 같은 타입으로 맞춰두니 키가 전부 string으로 취급되어 오타를 못 잡음
  • 유니온 타입을 기대했는데, 배열/객체가 “공통 분모”로 추론되어 정보가 사라짐
  • 특정 프로퍼티는 리터럴로 유지되어야 하는데, 추론이 바뀌며 boolean이나 string으로 넓어짐

이건 보통 TS가 “더 안전한 방향”으로 추론 규칙을 조정하거나, 제네릭/컨텍스트 타입의 영향이 달라지면서 발생합니다. 코드가 틀렸다기보다, 타입을 고정하는 방식이 취약했던 겁니다.

as 캐스팅이 문제를 키우는 이유

as는 “검증”이 아니라 “주장”입니다. 즉, 컴파일러에게 "내가 맞다고 했으니 믿어"라고 말하는 것에 가깝습니다. 그래서 다음 문제가 생깁니다.

  • 타입 불일치를 숨겨서 런타임 버그 가능성을 높임
  • 리터럴 타입이 유지되어야 하는데, 컨텍스트 타입에 의해 넓어지거나 반대로 너무 고정됨
  • 객체 리터럴의 초과 프로퍼티 체크(excess property check)가 약해짐

예를 들어 라우트 테이블을 만든다고 해봅시다.

type Route = {
  method: "GET" | "POST";
  path: string;
  auth: boolean;
};

const routes = [
  { method: "GET", path: "/health", auth: false },
  { method: "POST", path: "/login", auth: false },
  // 실수: method 오타
  { method: "GEET", path: "/oops", auth: false },
] as Route[];

위 코드는 as Route[] 때문에 "GEET" 같은 오타가 쉽게 묻힐 수 있습니다. 팀에서 TS를 올리고 나서 이런 류의 “숨겨진 주장”이 한꺼번에 드러나면, 사람 입장에서는 “추론이 깨졌다”로 체감됩니다.

satisfies란 무엇인가

satisfies는 다음 두 가지를 동시에 만족시키는 연산자입니다.

  1. 해당 값이 어떤 타입을 만족하는지 검증한다
  2. 값 자체의 추론 타입은 최대한 유지한다

즉, as처럼 타입을 강제로 덮어씌우지 않고, "이 값이 이 타입 조건을 충족하는지"만 검사합니다.

핵심 효과는 다음과 같습니다.

  • 리터럴 타입 보존(예: "GET"string으로 넓어지지 않음)
  • 초과 프로퍼티 체크가 살아있음
  • 키/값 매핑에서 오타를 더 잘 잡음

패턴 1: 라우트/설정 객체에서 리터럴 보존

앞의 라우트 예제를 satisfies로 바꿔봅니다.

type Route = {
  method: "GET" | "POST";
  path: string;
  auth: boolean;
};

const routes = [
  { method: "GET", path: "/health", auth: false },
  { method: "POST", path: "/login", auth: false },
  // { method: "GEET", path: "/oops", auth: false }, // 여기서 즉시 에러
] satisfies Route[];

이제 "GEET"는 즉시 에러가 납니다. 또한 routes[0].method는 여전히 리터럴에 가깝게 유지되어, 후속 로직에서 분기 처리가 더 정확해집니다.

왜 TS 5.5+에서 특히 체감이 큰가

TS 업그레이드로 인해 “컨텍스트 타입”의 영향이 달라지면, 기존에 as로 얼버무리던 코드들이 한꺼번에 흔들립니다. satisfies는 그 흔들림을 줄이는 가장 현실적인 해법입니다.

패턴 2: Record로 키를 넓히지 말고, satisfies로 검증만

국제화(i18n) 메시지나 에러 코드 매핑을 만들 때 Record<string, ...>를 자주 씁니다. 문제는 키가 전부 string이 되어버려 오타 방지가 약해진다는 점입니다.

type ErrorCode = "E_AUTH" | "E_RATE_LIMIT" | "E_UNKNOWN";

type ErrorMeta = {
  httpStatus: 400 | 401 | 429 | 500;
  retryable: boolean;
};

const errorMap = {
  E_AUTH: { httpStatus: 401, retryable: false },
  E_RATE_LIMIT: { httpStatus: 429, retryable: true },
  E_UNKNOWN: { httpStatus: 500, retryable: false },
  // E_UNKOWN: { httpStatus: 500, retryable: false }, // 오타를 잡고 싶다
} satisfies Record<ErrorCode, ErrorMeta>;

이 방식은 다음을 동시에 얻습니다.

  • ErrorCode에 없는 키를 추가하면 에러
  • ErrorMeta의 값 형태가 틀리면 에러
  • errorMap 자체는 객체 리터럴 기반의 좁은 추론이 유지되어, 키 자동완성이 좋아짐

429 같은 레이트 리밋을 다루는 곳에서는 이런 매핑이 특히 중요합니다. 재시도 여부(retryable)를 잘못 넣으면 장애가 증폭될 수 있으니, 타입으로 강하게 고정해두는 편이 안전합니다. 관련해서 레이트 리밋 폭주 패턴은 LangChain OpenAI 스트리밍 중 429 폭주 해결법도 함께 참고할 만합니다.

패턴 3: 유니온 기반 핸들러 테이블에서 추론 유지

이벤트/커맨드 핸들러를 테이블로 관리할 때, as는 핸들러 시그니처를 무너뜨리기 쉽습니다.

type Event =
  | { type: "LOGIN"; userId: string }
  | { type: "LOGOUT"; userId: string }
  | { type: "PURCHASE"; userId: string; amount: number };

type HandlerMap = {
  [K in Event["type"]]: (event: Extract<Event, { type: K }>) => Promise<void>;
};

const handlers = {
  LOGIN: async (e) => {
    e.userId;
  },
  LOGOUT: async (e) => {
    e.userId;
  },
  PURCHASE: async (e) => {
    e.amount;
  },
  // PURHCASE: async (e) => {}, // 오타 방지
} satisfies HandlerMap;

여기서 satisfies가 좋은 이유는, handlersHandlerMap으로 “변환”하지 않고 “검증”만 하기 때문입니다. 그래서 각 함수의 파라미터 e는 정확히 좁혀진 타입을 유지합니다.

이 패턴은 분산 트랜잭션이나 보상 트랜잭션처럼 이벤트 종류가 늘어나는 시스템에서 특히 유용합니다. 이벤트 중복 실행 방지 같은 설계를 한다면 Saga 보상 트랜잭션 중복 실행 방지 패턴도 같이 보면 전체 구조를 잡는 데 도움이 됩니다.

TS 5.5+ 업그레이드 시 점검 체크리스트

타입 추론이 바뀐 것처럼 보일 때, 아래를 먼저 점검하면 원인 파악이 빨라집니다.

1) as SomeType로 덮은 곳이 있는가

  • 설정 객체
  • 라우팅 테이블
  • 에러 코드 맵
  • 이벤트 핸들러 맵

이런 곳은 satisfies로 바꾸는 것만으로도 품질이 확 좋아집니다.

2) Record<string, ...>로 키를 넓혀버렸는가

키를 넓히면 오타 검출이 어려워집니다. 가능한 한 키 유니온을 만들고 Record<KeyUnion, ...>satisfies로 검증하세요.

3) 리터럴을 유지해야 하는데 string/number로 넓어졌는가

리터럴 기반 분기(예: switch on type)를 많이 쓰면, 타입이 넓어지는 순간 개발 경험이 급락합니다. satisfies는 리터럴 보존에 유리합니다.

실전 팁: satisfiesas const는 경쟁이 아니라 조합

as const는 값을 깊게 불변으로 만들고 리터럴을 최대한 고정합니다. satisfies는 타입 조건을 만족하는지 검증합니다. 둘은 자주 같이 씁니다.

type FeatureFlag = {
  key: string;
  enabledByDefault: boolean;
};

const flags = [
  { key: "newCheckout", enabledByDefault: false },
  { key: "fastPath", enabledByDefault: true },
] as const satisfies readonly FeatureFlag[];
  • as const로 배열/객체가 리터럴 중심으로 고정
  • satisfiesFeatureFlag 형태 검증

단, as const를 남발하면 타입이 너무 빡빡해져서 재사용이 어려워질 수 있습니다. “리터럴 유지가 필요한 경계(테이블/상수)”에만 적용하는 게 좋습니다.

satisfies로도 해결이 안 되는 케이스

satisfies는 만능은 아닙니다. 아래 상황에서는 다른 접근이 필요할 수 있습니다.

  • 값이 동적으로 생성되어 리터럴 보존 자체가 불가능한 경우
  • 제네릭 함수 내부에서 추론이 꼬이는 경우(오버로드나 명시적 타입 파라미터가 필요)
  • exactOptionalPropertyTypes 같은 컴파일 옵션 변경으로 의미가 달라진 경우

그래도 “상수/테이블/매핑” 류의 코드에서는 satisfies가 가장 비용 대비 효과가 큽니다.

마이그레이션 가이드: assatisfies로 바꾸는 순서

  1. 프로젝트에서 as 패턴을 검색해 “객체/배열 리터럴”에 붙은 캐스팅을 우선 찾기
  2. 그중에서 타입 안정성이 중요한 경계(라우트, 설정, 매핑, 핸들러 테이블)를 먼저 satisfies로 교체
  3. 교체 후 발생하는 타입 에러는 실제 버그 가능성이 높으니, 데이터 정합성을 맞추는 방향으로 수정
  4. 필요하면 as const satisfies ... 조합으로 리터럴 보존을 강화

업그레이드로 인해 드러난 타입 에러는 귀찮지만, 장기적으로는 장애를 줄여줍니다. 인프라에서 타임아웃이나 헬스체크 설정을 "맞춰두는 것"이 결국 장애를 줄이듯, 타입 시스템도 경계에서 강하게 잡아두는 게 유리합니다. 비슷한 관점의 운영 트러블슈팅은 AWS ALB 502·504 원인 - NLB·타임아웃·헬스체크도 참고할 수 있습니다.

정리

TS 5.5+에서 “타입 추론이 깨진 것 같다”는 느낌은 대개 업그레이드 자체보다, 기존 코드가 as 캐스팅에 기대어 취약하게 고정되어 있었기 때문에 발생합니다.

  • as는 타입을 덮어씌우는 주장이라서, 오타/불일치를 숨긴다
  • satisfies는 타입을 만족하는지 검증하면서도, 값의 좁은 추론을 유지한다
  • 라우트/설정/매핑/핸들러 테이블 같은 경계 코드에 satisfies를 적용하면 업그레이드 내성이 크게 올라간다

다음에 TS를 올렸는데 갑자기 자동완성이 약해지거나 타입이 넓어졌다면, 가장 먼저 “여기 as로 덮은 거 아닌가”를 의심하고 satisfies로 바꿔보는 걸 추천합니다.