Published on

TS 5.x satisfies로 타입 좁히기 실패 해결법

Authors

서론

TypeScript 5.x에서 satisfies는 “이 값이 어떤 타입 조건을 만족하는지”를 검증하는 데 탁월합니다. 하지만 많은 팀이 satisfiesas나 명시적 타입 주석처럼 “타입을 바꿔주는 도구”로 오해하면서, 조건문/가드/스위치에서 타입이 안 좁혀지는(또는 좁혀졌다고 착각하는) 문제가 자주 발생합니다.

핵심은 간단합니다.

  • satisfies표현식의 추론 타입을 바꾸지 않습니다.
  • 따라서 이후 제어 흐름에서 “그 타입을 기준으로” 좁히려 하면, 기대한 유니온 분해가 일어나지 않습니다.

이 글에서는 TS 5.x 환경에서 satisfies 때문에 발생하는 타입 좁히기 실패를 재현하고, 상황별로 가장 안전하고 유지보수 좋은 해결책을 정리합니다.

관련해서 satisfies로 타입 추론이 깨지는 케이스와 해결은 아래 글도 함께 보면 맥락이 더 잘 잡힙니다.

1) satisfies의 정확한 의미: “검증”이지 “주입”이 아니다

먼저 satisfies의 동작을 한 문장으로 정리하면:

> expr satisfies TexprT에 할당 가능함을 컴파일 타임에 확인하지만, expr의 타입은 그대로 둔다.

즉 아래처럼 “타입을 T로 만들었다”고 생각하면 오류가 납니다.

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rect"; width: number; height: number };

const s = {
  kind: "circle",
  radius: 10,
} satisfies Shape;

// s의 타입은 여전히 { kind: "circle"; radius: number }
// (혹은 리터럴 보존 여부에 따라 더 구체적/덜 구체적일 수 있음)

이 자체는 좋은데, 문제는 개발자가 s를 “Shape 유니온”으로 다루길 기대할 때입니다.

2) 실패 패턴 A: satisfies로 만든 객체가 유니온으로 좁혀지지 않는다

다음 코드는 얼핏 정상처럼 보이지만, 팀에서 자주 “왜 스위치에서 분기별 타입이 안 좁혀져?” 같은 질문을 유발합니다.

type Event =
  | { type: "CREATED"; payload: { id: string } }
  | { type: "DELETED"; payload: { id: string; hard: boolean } };

const e = {
  type: "DELETED",
  payload: { id: "a", hard: true },
} satisfies Event;

function handle(event: Event) {
  switch (event.type) {
    case "DELETED":
      event.payload.hard; // OK: Event로 받았으니 분기에서 좁혀짐
      break;
  }
}

// 그런데 e를 그대로 쓰면 팀이 기대한 방식과 어긋날 수 있음
switch (e.type) {
  case "DELETED":
    e.payload.hard; // 대개 OK이긴 하지만, 상황에 따라 좁히기 실패가 발생함
    break;
}

위 예시는 단순해서 통과할 때가 많습니다. 진짜 문제는 satisfies를 “타입 맵/레지스트리/핸들러 테이블”과 같이 쓸 때 터집니다.

3) 실패 패턴 B: 핸들러 테이블에서 분기 타입이 깨진다

실무에서 가장 흔한 형태는 “이벤트 타입 → 핸들러” 매핑입니다.

type Event =
  | { type: "CREATED"; payload: { id: string } }
  | { type: "DELETED"; payload: { id: string; hard: boolean } };

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

const handlers = {
  CREATED: (e) => {
    e.payload.id;
  },
  DELETED: (e) => {
    e.payload.hard;
  },
} satisfies HandlerMap;

function dispatch(e: Event) {
  handlers[e.type](e);
  // ^ 여기서 종종 에러가 난다:
  // Argument of type 'Event' is not assignable to parameter of type ...
}

왜냐하면 handlers[e.type]는 인덱싱 결과가 “각 핸들러의 유니온”처럼 보이고, 그 매개변수 타입이 Extract<...>로 서로 다르기 때문에 e: Event를 그대로 넣을 수 없다고 판단하는 경우가 많습니다.

여기서 개발자는 satisfies HandlerMap을 썼으니 handlers가 완벽한 HandlerMap이고, 따라서 handlers[e.type]도 알아서 맞을 거라 기대합니다. 하지만 TypeScript의 인덱싱/유니온 호출 규칙 때문에 그렇지 않습니다.

해결책 1: dispatch를 제네릭으로 만들어 상관관계를 유지

가장 깔끔하고 타입 안정성이 높은 방식입니다.

function dispatch<T extends Event["type"]>(e: Extract<Event, { type: T }>) {
  handlers[e.type](e);
}

// 사용 예
const deleted: Extract<Event, { type: "DELETED" }> = {
  type: "DELETED",
  payload: { id: "a", hard: true },
};

dispatch(deleted);

포인트는 e.typee의 구체 타입이 제네릭 T로 연결되어(상관관계가 유지되어) handlers[e.type]의 파라미터 타입과 정확히 일치한다는 점입니다.

해결책 2: 이벤트를 switch로 먼저 좁힌 뒤 호출

런타임 분기와 타입 분기를 같은 위치에 두는 방법입니다.

function dispatch(e: Event) {
  switch (e.type) {
    case "CREATED":
      handlers.CREATED(e);
      return;
    case "DELETED":
      handlers.DELETED(e);
      return;
  }
}

코드가 길어질 수 있지만, 디버깅과 추론 관점에서는 가장 직관적입니다.

해결책 3: 타입 단언(as)으로 땜질하지 말고 “타입 가드”를 제공

handlers[e.type](e as any) 같은 코드는 빨리 고장 납니다. 대신 타입 가드를 만들어 e를 좁힌 다음 호출하세요.

function isEvent<T extends Event["type"]>(
  e: Event,
  type: T
): e is Extract<Event, { type: T }> {
  return e.type === type;
}

function dispatch(e: Event) {
  if (isEvent(e, "CREATED")) {
    handlers.CREATED(e);
  } else if (isEvent(e, "DELETED")) {
    handlers.DELETED(e);
  }
}

4) 실패 패턴 C: satisfies + as const 조합에서 좁히기 기대가 어긋남

as const는 리터럴을 최대한 좁혀주고, satisfies는 조건만 검증합니다. 둘을 섞으면 “너무 좁거나/너무 넓은” 타입이 남아, 이후 코드에서 예상치 못한 형태로 좁혀지지 않을 수 있습니다.

예를 들어, 아래는 설정 객체를 만들 때 흔한 패턴입니다.

type Config = {
  mode: "dev" | "prod";
  retry: number;
};

const config = {
  mode: "dev",
  retry: 3,
} as const satisfies Config;

// config.retry는 3 (리터럴)로 고정됨
// 이후 retry를 number로 다루고 싶으면 오히려 불편해질 수 있음

해결책: as const는 “정말 필요한 곳”에만 제한적으로

  • 키 이름/디스크리미넌트(예: type, kind)만 리터럴로 고정하고 싶다면
  • 값 전체를 as const로 얼려버리는 대신, 특정 필드만 리터럴이 되도록 설계를 바꾸는 편이 낫습니다.

예시:

const config = {
  mode: "dev" as const,
  retry: 3,
} satisfies Config;

// retry는 number로 유지, mode만 "dev" 리터럴로 유지

5) 실패 패턴 D: satisfies로 “존재 체크”가 되는 줄 알고 좁히기 기대

satisfies는 타입 검증이라서, 런타임에서 값이 실제로 그 구조인지 확인해주지 않습니다. 따라서 외부 입력(JSON, API 응답)을 satisfies로 검사했다고 생각하면 좁히기 이전에 이미 전제가 깨집니다.

type ApiUser = { id: string; role: "admin" | "user" };

const u = (JSON.parse("{}") as unknown) satisfies ApiUser;
// 컴파일은 되더라도 런타임 안전성은 0

해결책: 런타임 스키마(예: zod) + 타입 가드로 좁히기

import { z } from "zod";

const ApiUserSchema = z.object({
  id: z.string(),
  role: z.enum(["admin", "user"]),
});

type ApiUser = z.infer<typeof ApiUserSchema>;

function parseUser(json: string): ApiUser {
  return ApiUserSchema.parse(JSON.parse(json));
}

여기서는 satisfies가 아니라 “검증 후 타입 확보”가 목적이므로 런타임 검증 도구가 맞습니다.

6) 실전 권장 패턴: satisfies는 ‘레지스트리 검증’에 쓰고, ‘호출’은 제네릭으로

정리하면 다음 조합이 가장 사고가 적습니다.

  1. 레지스트리(핸들러/라우트/커맨드 테이블)는 satisfies누락/오타/시그니처를 검증한다.
  2. 레지스트리를 인덱싱해서 호출하는 지점은 제네릭으로 상관관계(correlation) 를 유지한다.
type Command =
  | { cmd: "ADD"; a: number; b: number }
  | { cmd: "ECHO"; message: string };

type CommandMap = {
  [K in Command["cmd"]]: (c: Extract<Command, { cmd: K }>) => string;
};

const commandMap = {
  ADD: (c) => String(c.a + c.b),
  ECHO: (c) => c.message,
} satisfies CommandMap;

function run<C extends Command["cmd"]>(c: Extract<Command, { cmd: C }>) {
  return commandMap[c.cmd](c);
}

run({ cmd: "ADD", a: 1, b: 2 });
run({ cmd: "ECHO", message: "hi" });

이 패턴은 satisfies의 장점(검증)을 살리면서, TS가 약한 부분(유니온 인덱싱 호출)을 제네릭으로 보완합니다.

7) 디버깅 체크리스트: “왜 안 좁혀지지?”를 30초 만에 확인

타입 좁히기 실패를 보면 아래를 순서대로 확인하세요.

  1. satisfiesas처럼 쓰고 있지 않은가?

    • satisfies는 타입을 바꾸지 않습니다.
  2. 인덱싱(obj[key]) 후 호출하는가?

    • handlers[e.type](e) 형태는 상관관계가 끊어지기 쉽습니다.
  3. 분기 기준이 디스크리미넌트인가?

    • type/kind/tag 같은 문자열 리터럴 필드로 좁히는지 확인.
  4. as const로 값이 과도하게 리터럴화되었나?

    • 숫자/배열/객체 전체가 얼어버리면 오히려 사용성이 떨어집니다.
  5. 외부 입력을 satisfies로 “검증”했다고 착각했나?

    • 런타임 검증이 필요하면 zod/io-ts 등을 고려하세요.

8) 마무리: satisfies는 타입 안전을 ‘강화’하지만, 좁히기를 ‘대신’해주진 않는다

TS 5.x satisfies는 객체 리터럴에서 누락된 키, 잘못된 값 타입, 과한 타입 단언을 줄이는 데 매우 유용합니다. 하지만 타입 좁히기 문제는 대부분 satisfies 자체가 아니라,

  • 유니온 인덱싱 호출
  • 상관관계가 필요한 제네릭 설계 부재
  • as const 남용

에서 발생합니다.

핸들러/레지스트리는 satisfies로 검증하고, 실제 호출 지점은 제네릭(또는 switch로 명시적 분기)으로 상관관계를 유지하는 방식으로 바꾸면, “좁히기 실패”는 대부분 깔끔하게 사라집니다.

추가로 satisfies가 타입 추론을 망가뜨리는 케이스와 해결책은 아래 글도 참고하세요.