Published on

TS 5.5+ satisfies로 타입 좁히기 오류 잡기

Authors

서론

TypeScript로 API 라우팅 테이블, 이벤트 핸들러 맵, 권한 정책(Policy) 같은 “키-값 객체”를 만들다 보면, 타입을 엄격히 걸고 싶어서 as SomeType 또는 명시적 타입 어노테이션을 붙이게 됩니다. 그런데 이 순간부터 흔히 두 가지 문제가 터집니다.

  • 과도한 타입 고정: 객체 전체가 SomeType으로 “확정”되면서, 리터럴 타입 정보가 소실되거나(=widen) 반대로 너무 좁게 고정되어 이후 연산에서 유연성이 떨어집니다.
  • 타입 좁히기(narrowing) 실패: 유니언 분기나 키 기반 접근에서 “분명히 안전한데” 컴파일러가 따라오지 못해 에러가 납니다.

TypeScript 4.9에서 도입되고, 5.x에서 더 널리 쓰이게 된 satisfies는 이 문제를 해결하는 핵심 도구입니다. 요지는 간단합니다.

  • satisfies값의 형태가 특정 타입을 만족하는지 검증하되
  • 값 자체의 추론 타입은 바꾸지 않습니다.

이 글에서는 TypeScript 5.5+ 환경에서 특히 자주 마주치는 “타입 좁히기 오류”를 satisfies로 어떻게 해결하는지, 실전 패턴 중심으로 정리합니다.

> 운영 환경에서의 “진단/검증”이 중요하다는 점은 인프라 트러블슈팅과도 닮았습니다. 예를 들어 쿠버네티스에서 증상은 같아도 원인이 여러 갈래인 것처럼, 타입 에러도 원인이 widen인지, 인덱스 접근인지, 제네릭 분산 조건부인지에 따라 처방이 달라집니다. (참고: Kubernetes apiserver i/o timeout 원인과 해결)

satisfies가 해결하는 대표 증상

1) 객체를 타입 어노테이션으로 고정했더니 리터럴이 사라짐

아래 코드는 흔한 패턴입니다. 이벤트 이름과 페이로드 타입을 매핑하고 싶습니다.

type EventPayloads = {
  userCreated: { id: string; email: string };
  userDeleted: { id: string };
};

const payloads: EventPayloads = {
  userCreated: { id: "1", email: "a@b.com" },
  userDeleted: { id: "1" },
};

여기까지는 좋아 보이지만, 실제로는 “리터럴 기반의 정밀한 추론”이 필요한 순간이 옵니다. 예를 들어 키 목록을 뽑아 라우팅하거나, 특정 키만 허용하는 API를 만들 때입니다.

const keys = Object.keys(payloads);
// string[] 로 widen됨

payloadsEventPayloads로 고정되면, Object.keys는 구조적으로 string[]를 반환하고, 이후 코드에서 keyof EventPayloads로 좁히는 과정이 번거로워집니다.

satisfies를 쓰면 “검증은 하되, 값의 추론은 유지”할 수 있습니다.

type EventPayloads = {
  userCreated: { id: string; email: string };
  userDeleted: { id: string };
};

const payloads = {
  userCreated: { id: "1", email: "a@b.com" },
  userDeleted: { id: "1" },
} satisfies EventPayloads;

// payloads의 추론 타입은 리터럴 구조를 유지하면서
// 동시에 EventPayloads를 만족하는지 검사됨

핵심은 payloadsEventPayloads로 “캐스팅”된 것이 아니라, EventPayloads를 만족하는지 체크만 받았다는 점입니다.

2) as 캐스팅이 타입 좁히기를 망가뜨리는 경우

as는 “나는 안다”라고 컴파일러를 설득하는 도구입니다. 하지만 그 대가로 컴파일러가 제공하던 안전장치(특히 분기 좁히기)가 약해질 수 있습니다.

예를 들어 라우트 정의를 Record<string, Handler>로 캐스팅해버리면, 키가 구체적으로 무엇인지 사라져서 이후에 안전한 접근이 어려워집니다.

type Handler = (req: { path: string }) => string;

const routes = {
  "/health": (req) => "ok",
  "/users": (req) => "users",
} as Record<string, Handler>;

// 이제 routes["/health"]가 존재한다는 사실도
// keys가 "/health" | "/users"라는 사실도 사라짐

satisfies로 바꾸면, 핸들러 시그니처 검증은 유지하면서 키 유니언은 보존됩니다.

type Handler = (req: { path: string }) => string;

type RouteTable = Record<string, Handler>;

const routes = {
  "/health": (req: { path: string }) => "ok",
  "/users": (req: { path: string }) => "users",
} satisfies RouteTable;

type RoutePath = keyof typeof routes;
// "/health" | "/users"

패턴 1: “키 유니언”을 보존하면서 스키마 검증하기

현업에서 가장 많이 쓰는 케이스는 설정/정책/매핑 객체입니다. 예를 들어 권한 정책을 정의해봅시다.

type Role = "admin" | "member";

type Policy = {
  [K in Role]: {
    canRead: boolean;
    canWrite: boolean;
  };
};

const policy = {
  admin: { canRead: true, canWrite: true },
  member: { canRead: true, canWrite: false },
} satisfies Policy;

function canWrite(role: keyof typeof policy) {
  return policy[role].canWrite;
}

여기서 policy: Policy = ...로 선언했을 때도 동작은 하지만, satisfies를 쓰면 다음 이점이 생깁니다.

  • 리터럴 구조가 유지되어, keyof typeof policy가 정확해짐
  • 정책 객체에 오타 키가 들어가면 컴파일 타임에 잡힘
  • 각 값의 프로퍼티 누락/타입 불일치도 잡힘

이 패턴은 “설정이 맞는지 검증하되, 실제 코드에서는 최대한 구체 타입으로 다루고 싶다”는 요구에 정확히 들어맞습니다.

패턴 2: Discriminated Union에서 분기 좁히기 실패를 줄이기

유니언 타입을 다룰 때, 객체 리터럴을 “어떤 타입으로 선언하느냐”에 따라 분기 좁히기 성공/실패가 갈립니다.

예시로 액션(Action) 유니언을 보겠습니다.

type Action =
  | { type: "add"; value: number }
  | { type: "remove"; id: string };

function reducer(action: Action) {
  if (action.type === "add") {
    return action.value + 1;
  }
  return action.id;
}

문제는 액션 생성기/테이블을 만들 때 생깁니다.

const actions: Record<string, Action> = {
  addOne: { type: "add", value: 1 },
  removeA: { type: "remove", id: "a" },
};

이 자체는 맞지만, actions.addOne.type 같은 값이 필요할 때 Record<string, Action>로 고정되면서 키 정보가 사라지고, 특정 키에 대한 구체 액션 타입으로의 좁히기가 어려워집니다.

satisfies를 적용하면 “각 값이 Action임”은 보장하면서도, 키 기반 접근에서 더 나은 추론을 얻습니다.

type Action =
  | { type: "add"; value: number }
  | { type: "remove"; id: string };

const actions = {
  addOne: { type: "add", value: 1 },
  removeA: { type: "remove", id: "a" },
} satisfies Record<string, Action>;

// keyof 보존
type ActionName = keyof typeof actions; // "addOne" | "removeA"

function run(name: ActionName) {
  const action = actions[name];
  // action은 여전히 Action 유니언이지만,
  // name이 구체 리터럴이면 더 좁아질 여지가 생김
  return action.type;
}

여기서 한 단계 더 가면, 키와 값이 강하게 연결된 테이블도 만들 수 있습니다(아래 패턴 3).

패턴 3: “키-값 상관관계”를 유지해 좁히기까지 자동화

많은 타입 에러는 사실 “인덱스로 꺼낸 값이 유니언이라서” 발생합니다. 이때 원하는 건 name"addOne"이면 값도 {type:"add"...}로 좁혀지는 상관관계입니다.

이를 위해 테이블을 as const로 고정하고, satisfies로 스키마만 검증하는 조합이 강력합니다.

type Action =
  | { type: "add"; value: number }
  | { type: "remove"; id: string };

const actions = {
  addOne: { type: "add", value: 1 },
  removeA: { type: "remove", id: "a" },
} as const satisfies Record<string, Action>;

type ActionsMap = typeof actions;

type ActionOf<K extends keyof ActionsMap> = ActionsMap[K];

function run<K extends keyof ActionsMap>(name: K) {
  const action: ActionOf<K> = actions[name];

  if (action.type === "add") {
    // 여기서 action은 { type: "add"; value: 1 }
    return action.value + 1;
  }
  // 여기서 action은 { type: "remove"; id: "a" }
  return action.id;
}
  • as const는 값 리터럴을 최대한 좁게 고정합니다.
  • satisfies는 그 값이 Record<string, Action> 규격을 만족하는지만 확인합니다.

이 조합은 “테이블 기반 분기”에서 타입 좁히기 오류를 크게 줄여줍니다.

패턴 4: satisfies로 “과잉 속성(excess property)”을 정확히 잡기

객체 리터럴은 할당 시점에 “과잉 속성 검사”가 일어나는데, as를 쓰면 이 검사가 무력화되기 쉽습니다.

type User = { id: string; email: string };

// 위험: 과잉 속성도 통과시킬 수 있음
const u1 = { id: "1", email: "a@b.com", admin: true } as User;

satisfies는 캐스팅이 아니라 검증이므로, 이런 실수를 잡습니다.

type User = { id: string; email: string };

const u2 = {
  id: "1",
  email: "a@b.com",
  admin: true,
} satisfies User;
// 에러: 'admin'은 User에 없음

설정 파일/환경 변수 매핑/피처 플래그 같은 곳에서 특히 유용합니다. “문법적으로는 맞는데 운영에서 터지는” 종류의 실수를 컴파일 타임으로 끌어오는 효과가 있습니다. 운영 장애를 줄이기 위한 체크리스트를 미리 만드는 것과 유사한 접근입니다(참고: EKS Ingress 503인데 Pod 정상일 때 점검 가이드).

TypeScript 5.5+에서 체감이 커지는 이유

TypeScript는 버전이 올라가며 객체 리터럴, 제네릭, 컨트롤 플로우 분석이 지속적으로 개선됩니다. 5.5+에서는 특히 “추론 결과를 최대한 활용하는 코드”가 더 이득을 봅니다.

  • satisfies는 추론을 포기하지 않고 타입 안전성을 올리는 방식이라, 최신 TS의 개선을 그대로 흡수합니다.
  • 반대로 as SomeType 남발은 추론/분석 개선의 혜택을 스스로 차단합니다.

즉, TS가 똑똑해질수록 satisfies는 더 좋은 기본값이 됩니다.

자주 하는 실수와 체크포인트

1) satisfies는 “타입을 바꾸지 않는다”

아래는 기대와 다른 동작으로 헷갈리기 쉬운 예입니다.

type Shape = { kind: "circle"; r: number } | { kind: "square"; w: number };

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

// s는 Shape가 아니라, { kind: "circle"; r: number }로 추론됨

이건 단점이 아니라 장점입니다. 다만 “변수를 유니언으로 다루고 싶다”면 별도로 어노테이션을 해야 합니다.

const s2: Shape = { kind: "circle", r: 10 };

정리하면:

  • 검증 + 구체 추론 유지: const x = {...} satisfies T
  • 그 변수 자체를 T로 취급: const x: T = {...}

2) 인덱스 접근에서 여전히 string 문제가 남는 경우

Object.keys()는 기본적으로 string[]를 반환하므로, 아래는 여전히 타입 캐스팅/헬퍼가 필요합니다.

const keys = Object.keys(actions); // string[]

이때는 안전한 헬퍼를 만들어 해결합니다.

function typedKeys<T extends object>(obj: T) {
  return Object.keys(obj) as Array<keyof T>;
}

const keys = typedKeys(actions); // ("addOne" | "removeA")[]

satisfies는 “객체 정의 시점의 검증/추론”을 돕는 도구이고, 런타임 내장 함수의 타입 한계를 자동으로 바꾸진 않습니다.

결론: as 대신 satisfies를 기본으로 두기

TypeScript 5.5+에서 satisfies는 다음 상황에서 특히 강력합니다.

  • 라우팅 테이블/핸들러 맵/정책/설정처럼 객체 리터럴을 선언할 때
  • Record<...>나 매핑 타입을 만족해야 하지만, 키 유니언과 리터럴 정보를 유지하고 싶을 때
  • as로 인해 과잉 속성 검사나 타입 좁히기가 깨지는 문제를 줄이고 싶을 때

실무 기준의 추천 규칙은 간단합니다.

  • “검증만” 필요하면 satisfies
  • 정말로 그 변수 타입을 바꿔야 할 때만 : T 또는 제한적으로 as
  • 테이블 기반 상관관계가 필요하면 as const satisfies ... 조합

타입 시스템은 결국 운영 안정성과 개발 속도를 동시에 올리는 장치입니다. 타입 에러를 억지로 눌러버리기보다(as), 원인을 구조적으로 해결하는(satisfies) 쪽이 장기적으로 비용이 적게 듭니다.