Published on

TS 5.7 - satisfies로 타입 좁히기 실패 해결

Authors

서론

as constsatisfies를 같이 쓰면 “검증도 되고 타입도 예쁘게 좁혀지겠지”라고 기대하기 쉽습니다. 하지만 satisfies타입을 강제 캐스팅하는 문법이 아니라, “이 값이 특정 타입 조건을 만족하는지”를 검증하는 용도입니다. 즉, satisfies 자체는 값의 추론 타입을 바꾸지 않습니다.

TS 5.7로 오면서 타입 추론/표현이 더 정교해진 만큼, 기존에 “우연히” 좁혀지던 코드가 더 이상 좁혀지지 않거나(혹은 반대로 더 넓게 남아) 컴파일 에러가 드러나는 케이스가 많습니다. 이 글에서는 satisfies로 타입 좁히기가 실패하는 대표적인 패턴을 재현하고, TS 5.7 기준으로 가장 안전한 해결책들을 제공합니다.

> 참고: 선언 파일 생성/빌드 파이프라인에서 타입 추론이 예민해지는 맥락은 TS 5.5+ isolatedDeclarations 에러 실전 해결법에서도 함께 다룹니다.

satisfies의 핵심: “검증”이지 “변환”이 아니다

먼저 문장을 하나로 정리하면 이렇습니다.

  • expr satisfies Texpr이 T에 할당 가능(assignable)한지 확인한다.
  • 하지만 expr의 타입은 여전히 expr의 추론 타입으로 남는다.

이 차이를 이해하면, 왜 좁히기가 실패하는지 대부분 설명됩니다.

실패 재현 1: Record로 검증했더니 key/value가 넓게 남음

아래 코드를 보면, 우리는 handlers의 키가 "start" | "stop"로, 값이 함수로 잘 좁혀지길 기대합니다.

type Action = "start" | "stop";

const handlers = {
  start: () => "ok",
  stop: () => "ok",
} satisfies Record<Action, () => string>;

// 기대: keyof typeof handlers === "start" | "stop"
// 현실: 여기까진 보통 괜찮아 보이지만, 아래에서 종종 문제가 터집니다.

function run(action: Action) {
  // 문제: handlers[action]가 항상 존재한다고 확신하고 싶다.
  return handlers[action]();
}

이 코드는 많은 경우 통과합니다. 하지만 조금만 형태가 바뀌면(옵셔널/부분 매핑/유니온 등) handlers[action]undefined 가능성이 생기며 좁히기 실패가 드러납니다.

실패 재현 2: Partial/옵셔널을 섞는 순간 undefined가 남는다

type Action = "start" | "stop";

const handlers = {
  start: () => "ok",
  // stop intentionally missing
} satisfies Partial<Record<Action, () => string>>;

function run(action: Action) {
  // TS: Object is possibly 'undefined'.
  return handlers[action]();
}

여기서 “satisfies Partial<Record<...>>로 검증했으니, action이 start일 때는 좁혀지지 않나?”라고 생각하기 쉽습니다. 하지만 action: Action은 런타임에서 어떤 값이 들어올지 모르므로, TS는 handlers[action](() => string) | undefined로 유지합니다.

satisfies는 “이 객체가 Partial<Record<...>> 형태로 맞다”만 보장할 뿐, action에 따라 인덱싱 결과를 똑똑하게 좁혀주지는 않습니다.

TS 5.7에서 더 자주 드러나는 이유: “표현은 유지, 검증만 강화”

TS 팀이 satisfies를 설계한 의도는 “값의 구체적 리터럴 타입을 유지하면서도, 특정 인터페이스/제네릭 계약을 검증하고 싶다”입니다.

  • as SomeType은 타입을 바꿔버려서 리터럴 정보가 사라질 수 있습니다.
  • satisfies SomeType은 리터럴 정보는 유지하고, 계약만 체크합니다.

그런데 이 “리터럴 정보 유지”가 만능 좁히기가 아닙니다. 특히 아래 상황에서 착각이 발생합니다.

  • 인덱스 접근(obj[key]): key가 유니온이면 결과도 유니온(또는 undefined 포함)으로 남기 쉽다.
  • Partial/옵셔널: 존재성은 타입 시스템이 보수적으로 본다.
  • 유니온 객체: satisfies는 유니온 분기를 자동으로 좁히지 않는다.

해결 패턴 1: 가장 단단한 방법 — 명시적 타입 가드로 좁히기

Partial<Record<...>>처럼 “없을 수도 있는” 구조라면, 결국 런타임 체크가 필요합니다. 이때는 타입 가드를 만들어 좁히는 게 가장 명확합니다.

type Action = "start" | "stop";

const handlers = {
  start: () => "ok",
} satisfies Partial<Record<Action, () => string>>;

function hasHandler(
  key: Action,
): key is keyof typeof handlers {
  return key in handlers;
}

function run(action: Action) {
  if (!hasHandler(action)) {
    throw new Error(`No handler for ${action}`);
  }
  // 여기서 action은 keyof typeof handlers로 좁혀짐
  return handlers[action]!();
}

포인트는 key in handlers 같은 런타임 체크를 통해 TS가 납득할 수 있는 “존재성” 근거를 제공하는 것입니다.

해결 패턴 2: “모든 키가 반드시 있어야 함”을 타입으로 강제하기

실제로는 모든 액션에 핸들러가 있어야 한다면, 애초에 Partial을 쓰지 말고 완전 매핑을 강제하세요.

type Action = "start" | "stop";

type HandlerMap = Record<Action, () => string>;

const handlers = {
  start: () => "ok",
  stop: () => "ok",
} satisfies HandlerMap;

function run(action: Action) {
  // undefined 가능성이 사라짐
  return handlers[action]();
}

이 패턴은 satisfies의 장점을 잘 살립니다.

  • 객체 리터럴의 구체적 형태(리터럴)를 유지
  • 동시에 Record<Action, ...> 계약 위반을 컴파일 타임에 차단

해결 패턴 3: 인덱싱 좁히기가 목적이면 “키 목록”을 먼저 고정하기

obj[key]에서 key가 유니온이면 결과가 넓어지는 문제는 흔합니다. 이때는 키를 배열로 고정하고, 그 배열을 기반으로 순회/검증하도록 설계를 바꾸면 타입이 깔끔해집니다.

const actions = ["start", "stop"] as const;
type Action = (typeof actions)[number];

const handlers = {
  start: () => "ok",
  stop: () => "ok",
} satisfies Record<Action, () => string>;

for (const a of actions) {
  // a는 "start" | "stop"이지만, 루프 컨텍스트에서 안전
  handlers[a]();
}

이 방식은 “가능한 키의 집합”을 코드로도 명확히 남겨서, 런타임/타입 모두에서 유지보수성이 좋습니다.

해결 패턴 4: satisfies + as const 조합의 올바른 사용 위치

많이 하는 실수는 satisfies만으로 리터럴을 고정하려는 것입니다. 리터럴 고정은 as const가 담당합니다.

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

const route = {
  path: "/health",
  method: "GET",
} as const satisfies Route;

// route.method는 "GET" (리터럴) 유지
// 동시에 Route 계약도 만족해야 함

순서도 중요합니다.

  • as const satisfies T는 “리터럴로 고정한 값을 T로 검증”
  • 반대로 satisfies T as const 같은 형태는 불가능하고, as T를 섞으면 리터럴이 사라질 수 있습니다.

해결 패턴 5: 유니온 객체에서 satisfies로 분기 좁히려 하지 말기

유니온 타입을 만족하는지 검증하려고 satisfies를 쓰면, “그럼 분기가 좁혀지겠지”라고 기대할 수 있습니다. 하지만 satisfies는 분기를 선택하지 않습니다.

type Ok = { ok: true; value: number };
type Err = { ok: false; error: string };
type Result = Ok | Err;

const r = {
  ok: true,
  value: 123,
} satisfies Result;

// r의 타입은 여전히 { ok: true; value: 123 }에 가깝게 유지되지만,
// "Result로서의 제어 흐름"을 자동 획득하는 건 아닙니다.

function use(res: Result) {
  if (res.ok) {
    // Ok로 좁혀짐
    return res.value;
  }
  return res.error;
}

핵심은: **제어 흐름 기반 좁히기(control flow narrowing)**는 함수 인자/조건문에서 일어나지, satisfies가 대신해주지 않습니다.

실전에서 자주 만나는 “satisfies 좁히기 실패” 체크리스트

1) “검증”이 필요한가, “변환/캐스팅”이 필요한가

  • 계약을 만족하는지 확인만 필요 → satisfies
  • 외부 입력(JSON 등)을 특정 타입으로 단언해야 함 → 런타임 검증(zod 등) + 타입 가드/파서
  • 임시로 우겨넣기 → as (가능하면 지양)

2) 인덱스 접근에서 undefined가 남는 건 정상이다

Partial<Record<...>>면 특히 그렇습니다. 이건 TS가 보수적인 게 아니라, 런타임 현실과 맞습니다.

3) “키가 반드시 존재”해야 하면 타입부터 바꿔라

Partial을 쓰고 “없으면 안 되는데…”라고 사후 처리하면 코드가 더 복잡해집니다.

예제: API 라우트 테이블에서의 실패와 해결

Next.js/Express류에서 라우트 테이블을 만들 때 satisfies를 자주 씁니다.

문제 코드

type Method = "GET" | "POST";
type RouteKey = `${Method} /health` | `${Method} /login`;

type Handler = (req: unknown) => Promise<unknown>;

const routes = {
  "GET /health": async () => ({ ok: true }),
  // "POST /login" missing
} satisfies Partial<Record<RouteKey, Handler>>;

async function dispatch(key: RouteKey, req: unknown) {
  // TS: possibly undefined
  return routes[key](req);
}

해결 1: 반드시 있어야 하는 라우트만 Record로 강제

const routes = {
  "GET /health": async () => ({ ok: true }),
  "POST /login": async () => ({ token: "..." }),
} satisfies Record<RouteKey, Handler>;

async function dispatch(key: RouteKey, req: unknown) {
  return routes[key](req);
}

해결 2: 일부만 허용할 거면 타입 가드 + 에러 처리

const routes = {
  "GET /health": async () => ({ ok: true }),
} satisfies Partial<Record<RouteKey, Handler>>;

function assertRoute(
  key: RouteKey,
): asserts key is keyof typeof routes {
  if (!(key in routes)) {
    throw new Error(`Route not registered: ${key}`);
  }
}

async function dispatch(key: RouteKey, req: unknown) {
  assertRoute(key);
  return routes[key]!(req);
}

이 패턴은 런타임 안전성과 타입 안전성을 동시에 챙깁니다.

TS 5.7 마이그레이션 팁: “타입 시스템에 기대지 말고, 계약을 분리”

TS 버전이 올라가면서 생기는 체감 문제의 상당수는 “한 줄로 다 해결하려는 코드”에서 발생합니다.

  • satisfies로 계약 검증
  • as const로 리터럴 고정
  • 제어 흐름 좁히기는 타입 가드/런타임 체크

이렇게 역할을 분리하면 TS 5.7에서도 예측 가능한 타입을 얻습니다.

빌드/배포 파이프라인에서 타입 문제를 빨리 잡는 것도 중요합니다. 예를 들어 CI에서 캐시가 꼬여 오래된 TS 버전/산출물이 남아 있으면 “내 로컬에선 되는데 CI에서만 타입 에러” 같은 형태로 보일 수 있습니다. 이런 경우는 GitHub Actions 캐시가 안 먹을 때 키·경로·권한처럼 캐시 키/경로를 점검해 재현성을 확보하는 게 좋습니다.

결론

TS 5.7에서 satisfies로 타입 좁히기가 “실패”하는 것처럼 보이는 이유는, satisfies가 애초에 타입을 바꾸는 문법이 아니라 계약을 검증하는 문법이기 때문입니다. 특히 Partial, 인덱스 접근, 유니온 분기 같은 구간에서는 제어 흐름 기반 좁히기를 별도로 제공해야 합니다.

정리하면 다음 3가지만 기억하면 됩니다.

  1. 리터럴 고정은 as const, 계약 검증은 satisfies
  2. 인덱싱 결과의 undefined는 타입 가드/런타임 체크로 해결
  3. “반드시 존재해야 하는 키”는 Record로 강제해 설계를 단순화

이 원칙대로 코드를 정리하면 TS 5.7에서도 타입이 흔들리지 않고, 오히려 컴파일러가 더 많은 실수를 조기에 잡아주는 쪽으로 체감이 바뀔 것입니다.