Published on

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

Authors

TypeScript 5.x에서 satisfies를 도입하면 객체 리터럴이 특정 타입을 "만족"하는지 검증하면서도, 값의 더 구체적인 리터럴 타입을 최대한 유지할 수 있습니다. 그래서 많은 코드베이스가 as SomeType 캐스팅을 줄이기 위해 satisfies로 빠르게 이동했습니다.

그런데 실무에서 자주 부딪히는 함정이 있습니다. satisfies를 썼는데도 분기문에서 타입 좁힘이 기대대로 되지 않거나, 인덱싱/매핑 시점에 타입이 string이나 unknown처럼 넓게 남아 결국 또 캐스팅을 하게 되는 문제입니다.

이 글은 "왜 좁힘이 실패하는지"를 TS 타입 시스템 관점에서 설명하고, 실패 패턴별로 가장 유지보수 좋은 해결법을 제시합니다.

추가로 TS 5.5+에서 선언 추론이 더 엄격해지며 관련 이슈가 함께 터지는 경우가 있어, 필요하면 TS 5.5+ isolatedDeclarations 에러 실전 해결법도 같이 참고하면 좋습니다.

satisfies가 하는 일과 안 하는 일

핵심만 정리하면 다음과 같습니다.

  • expr satisfies TexprT에 할당 가능(assignable)한지 검사합니다.
  • 하지만 expr의 타입을 T바꾸지 않습니다.
  • 따라서 satisfies는 "검증"이지 "주석(annotation)"이 아닙니다.

즉, 아래 코드는 obj의 타입을 HandlerMap으로 만들지 않습니다.

type HandlerMap = Record<string, (x: number) => string>;

const obj = {
  a: (x: number) => `a:${x}`,
  b: (x: number) => `b:${x}`,
} satisfies HandlerMap;

// obj의 타입은 여전히 { a: ..., b: ... } 기반으로 추론됨

이 특성 때문에, satisfies를 쓴 뒤에 "타입 좁힘이 자연스럽게 될 것"이라고 기대하면 어긋나는 지점이 생깁니다.

실패 패턴 1: 유니온 판별식(discriminant)이 넓게 추론됨

가장 흔한 케이스는 "판별식이 리터럴로 유지되지 않는" 상황입니다.

문제 예시

type Event =
  | { type: "created"; id: string }
  | { type: "deleted"; id: string; hard: boolean };

const e = {
  type: "deleted",
  id: "1",
  hard: true,
} satisfies Event;

if (e.type === "deleted") {
  // 기대: e는 deleted 케이스로 좁혀져 hard 접근 가능
  // 실제: 상황에 따라 e.hard에서 에러가 나거나,
  //       다른 연산에서 좁힘이 깨지는 경우가 생김
  console.log(e.hard);
}

겉보기엔 문제 없어 보이지만, 프로젝트 설정(특히 exactOptionalPropertyTypes, noUncheckedIndexedAccess)과 결합되거나, e를 다른 함수로 넘기는 순간 타입이 다시 넓어지며 좁힘이 약해지는 일이 발생합니다.

원인

  • satisfies는 "이 값이 Event 유니온 중 하나에 들어맞는다"만 확인합니다.
  • 하지만 e 자체의 타입은 "객체 리터럴의 추론 결과"로 남습니다.
  • 이 추론 결과가 유니온과 정확히 같은 형태가 아닐 수 있고, 이후 흐름에서 Event로 다시 취급되는 과정에서 정보 손실이 생깁니다.

해결 1: as const로 판별식을 리터럴로 고정

const e = {
  type: "deleted",
  id: "1",
  hard: true,
} as const satisfies Event;

if (e.type === "deleted") {
  console.log(e.hard);
}
  • as const는 리터럴 유지에 강력합니다.
  • 단, 전체가 readonly가 되므로 이후 변경이 필요한 값에는 부적합합니다.

해결 2: const 제네릭 팩토리로 "리터럴 유지 + 타입 검증" 동시 달성

변경 가능해야 한다면 팩토리 패턴이 깔끔합니다.

type Event =
  | { type: "created"; id: string }
  | { type: "deleted"; id: string; hard: boolean };

function defineEvent<const T extends Event>(e: T) {
  return e;
}

const e = defineEvent({
  type: "deleted",
  id: "1",
  hard: true,
});

if (e.type === "deleted") {
  console.log(e.hard);
}
  • const 제네릭은 리터럴을 최대한 유지합니다.
  • satisfies 없이도 "검증" 역할을 합니다.
  • 특히 이벤트/액션/메시지 스키마 정의에 가장 실용적인 패턴입니다.

실패 패턴 2: Record<string, ...>로 만족시키면 키가 string으로 붕괴

Record<string, T>는 구조적으로 매우 넓습니다. 이걸 satisfies로 만족시키면, 객체 리터럴의 키 집합을 잃어버리는 것처럼 느끼는 문제가 생깁니다.

문제 예시

type Handlers = Record<string, (payload: unknown) => void>;

const handlers = {
  login: (p: { userId: string }) => {
    console.log(p.userId);
  },
  logout: (_p: {}) => {},
} satisfies Handlers;

function call(name: string, payload: unknown) {
  // name이 string이라면 핸들러 존재 여부가 불명확
  // handlers[name]는 (payload: unknown) => void | undefined 같은 형태로 넓어짐
  handlers[name](payload);
}

여기서 좁힘이 실패하는 본질은 satisfies가 아니라, Record<string, ...>string 인덱싱 조합이 "항상 있을 거"라는 보장을 못 한다는 점입니다.

해결 1: 키를 유니온으로 고정하고, 호출 API도 그 유니온을 사용

const handlers = {
  login: (p: { userId: string }) => {
    console.log(p.userId);
  },
  logout: (_p: {}) => {},
} satisfies Record<string, (payload: unknown) => void>;

type HandlerName = keyof typeof handlers;

function call(name: HandlerName, payload: unknown) {
  handlers[name](payload);
}

call("login", { userId: "u1" });

이 방식의 포인트는 "키의 집합"을 keyof typeof handlers에서 얻고, 외부 API가 그 집합을 따르게 만드는 것입니다.

해결 2: 페이로드까지 타입 안전하게 만들기 (가장 추천)

핸들러별 payload 타입이 다르면, 아래처럼 "이벤트 이름으로 payload를 매핑"하는 설계가 좋습니다.

type PayloadByName = {
  login: { userId: string };
  logout: {};
};

type HandlerMap = {
  [K in keyof PayloadByName]: (payload: PayloadByName[K]) => void;
};

const handlers = {
  login: (p) => {
    console.log(p.userId);
  },
  logout: (_p) => {},
} satisfies HandlerMap;

function call<K extends keyof HandlerMap>(name: K, payload: PayloadByName[K]) {
  handlers[name](payload);
}

call("login", { userId: "u1" });
// call("login", {}); // 컴파일 에러
  • satisfies는 여기서 "구현이 스펙을 만족"하는지 검증합니다.
  • 좁힘은 K 제네릭과 인덱스 접근 타입이 담당합니다.

실패 패턴 3: satisfies 뒤에 변수로 빼는 순간 리터럴이 넓어짐

객체를 조립하는 과정에서 중간 변수를 let으로 두거나, 리터럴이 아닌 표현식이 끼면 리터럴 타입이 string 등으로 넓어질 수 있습니다.

문제 예시

type Mode = "dev" | "prod";

declare const env: string;

const cfg = {
  mode: env,
} satisfies { mode: Mode };

// env가 string이므로 cfg.mode는 string이고,
// 이후 분기에서 "dev" | "prod" 좁힘을 기대할 수 없음

해결 1: 런타임 가드로 먼저 좁히고, 그 결과를 사용

type Mode = "dev" | "prod";

function isMode(x: string): x is Mode {
  return x === "dev" || x === "prod";
}

declare const env: string;

const mode: Mode = isMode(env) ? env : "prod";

const cfg = {
  mode,
} satisfies { mode: Mode };
  • satisfies는 검증용으로만 두고,
  • 실제 좁힘은 타입 가드에서 책임지게 하면 예측 가능성이 높아집니다.

해결 2: zod 같은 스키마 검증 라이브러리와 결합

프로덕션에서는 문자열 환경변수처럼 "외부 입력"이 많습니다. 이때는 타입 가드 수동 작성보다 스키마 검증이 더 안전합니다.

import { z } from "zod";

type Mode = "dev" | "prod";

const ModeSchema = z.union([z.literal("dev"), z.literal("prod")]);

declare const env: string;

const mode = ModeSchema.catch("prod").parse(env) satisfies Mode;

const cfg = { mode } satisfies { mode: Mode };

주의: 위처럼 satisfies를 값에 직접 붙이면 가독성이 떨어질 수 있으니, 팀 컨벤션에 맞춰 정리하는 게 좋습니다.

실패 패턴 4: satisfies는 제어 흐름 기반 좁힘을 "강화"하지 않는다

종종 이런 기대를 합니다.

  • "satisfies로 타입을 지정했으니, if에서 더 잘 좁혀지겠지"

하지만 제어 흐름 기반 좁힘은 기본적으로 변수의 타입가드 조건으로만 결정됩니다. satisfies는 변수 타입을 바꾸지 않으므로, 좁힘 성능이 드라마틱하게 좋아지지 않습니다.

해결: 좁힘이 필요한 지점에선 satisfies 대신 "타입을 실제로 부여"하는 방식을 쓰기

예를 들어, API 응답을 내부 도메인 타입으로 다루고 싶다면 satisfies보다 다음이 명확합니다.

type User = { id: string; role: "admin" | "member" };

function assertUser(x: unknown): asserts x is User {
  if (
    typeof x !== "object" ||
    x === null ||
    !("id" in x) ||
    !("role" in x)
  ) {
    throw new Error("Invalid user");
  }
}

const data: unknown = JSON.parse("{\"id\":\"1\",\"role\":\"admin\"}");
assertUser(data);

// 여기서부터 data는 User로 "실제로" 좁혀짐
if (data.role === "admin") {
  // admin 분기
}
  • satisfies는 "정적" 검증 도구
  • asserts/타입 가드는 "동적" 입력을 정적으로 변환하는 도구

둘의 역할을 분리하면 좁힘 실패로 인한 캐스팅이 줄어듭니다.

실전 레시피: satisfies를 써도 좁힘이 잘 되게 만드는 규칙

1) 객체 스펙 검증은 satisfies, 호출/사용은 keyof typeof와 제네릭

const routes = {
  home: "/",
  user: "/users/:id",
} satisfies Record<string, string>;

type RouteName = keyof typeof routes;

function href(name: RouteName) {
  return routes[name];
}
  • satisfies는 "값이 문자열인지"만 확인
  • 타입 안전성은 RouteName로 확보

2) 판별 유니온은 const 제네릭 팩토리로 정의

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

function defineAction<const T extends Action>(a: T) {
  return a;
}

const a = defineAction({ type: "add", value: 1 });

if (a.type === "add") {
  a.value;
}

3) 외부 입력은 먼저 런타임 검증으로 좁히고, 그 다음 satisfies

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

function parseConfig(x: unknown): Config {
  if (typeof x !== "object" || x === null) throw new Error("bad");
  const r = x as { mode?: unknown; port?: unknown };
  const mode = r.mode === "dev" || r.mode === "prod" ? r.mode : "prod";
  const port = typeof r.port === "number" ? r.port : 3000;
  return { mode, port };
}

const cfg = parseConfig(JSON.parse("{}")) satisfies Config;

디버깅 팁: 왜 좁힘이 안 되는지 빠르게 확인하는 방법

  1. 변수에 마우스를 올려 "현재 타입"을 확인합니다.
  • 여기서 string으로 넓어져 있으면, 분기문이 아무리 좋아도 좁힘이 제한됩니다.
  1. 중간 단계에 타입 별칭을 만들어 keyof typeof를 눈에 보이게 둡니다.
const handlers = {
  login: (p: { userId: string }) => {},
  logout: (_p: {}) => {},
} satisfies Record<string, (payload: unknown) => void>;

type Names = keyof typeof handlers;
// Names가 "login" | "logout"인지 확인
  1. noUncheckedIndexedAccess가 켜져 있다면 인덱싱 결과에 undefined가 섞이는 게 정상입니다.
  • 이 경우는 좁힘 실패가 아니라 "존재성 체크가 필요"하다는 신호입니다.

결론: satisfies는 검증 도구, 좁힘은 설계로 해결

  • satisfies는 "타입을 바꾸지 않는다"는 점을 항상 염두에 둬야 합니다.
  • 타입 좁힘이 필요한 문제는 대개
    • 키를 string으로 열어둔 설계,
    • 외부 입력을 정적으로 믿는 설계,
    • 판별 유니온을 리터럴로 유지하지 못한 설계 에서 발생합니다.

따라서 해결책도 satisfies 자체의 트릭보다,

  • keyof typeof로 키 집합을 고정하고
  • 제네릭으로 호출부를 연결하며
  • 외부 입력은 런타임 검증으로 먼저 좁히는 방향이 가장 안정적입니다.

빌드/툴링 이슈까지 함께 겪고 있다면, TS 5.5+ 변화로 인해 선언 추론이 까다로워진 케이스도 많으니 TS 5.5+ isolatedDeclarations 에러 실전 해결법도 같이 점검해보면 문제 원인을 더 빨리 좁힐 수 있습니다.