Published on

TS 5.x satisfies로 타입 좁힘이 안될 때 해결법

Authors
Binance registration banner

서론

TypeScript 4.9부터 도입된 satisfies는 “이 값이 특정 타입의 제약을 만족하는지”를 컴파일 타임에 검증해주는 매우 유용한 연산자입니다. 특히 객체 리터럴에 대해 과잉 속성 검사(excess property check) 를 유지하면서도, 값의 “구체적인 리터럴 타입”을 최대한 보존할 수 있다는 점 때문에 설정 객체, 라우팅 테이블, 이벤트 맵 등에서 자주 사용됩니다.

그런데 실무에서 satisfies를 붙였더니 기대했던 타입 좁힘(narrowing) 이 되지 않거나, 오히려 특정 프로퍼티가 string/number처럼 넓게 추론되어 분기문에서 안전하게 다루기 어려워지는 경우가 있습니다. 이 글은 “왜 그런가?”를 TS의 타입 추론 규칙 관점에서 설명하고, 바로 적용 가능한 해결 패턴을 코드로 정리합니다.

(참고로 문제 원인이 ‘검증은 되는데 런타임에서만 터지는’ 종류라면, 장애 재현/원인 격리 패턴이 도움이 됩니다. 비슷한 접근으로 OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드도 함께 보면 좋습니다.)

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

가장 중요한 문장 하나만 기억하면 됩니다.

  • expr satisfies Texpr의 타입을 T로 캐스팅하지 않습니다.
  • 단지 expr의 타입이 T에 할당 가능한지 검사하고, 결과 타입은 기본적으로 expr의 추론 타입을 유지합니다.

as T와 목적이 다릅니다.

  • as T: 타입을 T로 “단언”해서 이후 타입 체커가 T로 취급
  • satisfies T: 타입은 그대로 두되 “T를 만족함”을 검증

이 차이 때문에 “satisfies를 붙였으니 이제 이 값은 T로 좁혀지겠지?”라고 기대하면 어긋납니다.

2) satisfies를 썼는데 좁힘이 안 되는 대표 증상

증상 A: 유니온(discriminated union) 분기가 안 먹는다

예를 들어, kind로 분기하는 전형적인 discriminated union을 생각해봅시다.

type Command =
  | { kind: "create"; payload: { name: string } }
  | { kind: "delete"; payload: { id: string } };

const cmd = {
  kind: "create",
  payload: { name: "alice" },
} satisfies Command;

if (cmd.kind === "create") {
  cmd.payload.name; // 기대: OK
  // cmd.payload.id; // 기대: 에러
}

이 케이스는 대체로 잘 동작합니다. 그런데 아래처럼 객체를 한 번 더 감싸거나, 인덱싱/맵 형태로 저장하는 순간부터 문제가 생기기 쉽습니다.

증상 B: 맵/레지스트리에 넣자마자 리터럴이 넓어져 분기 불가

type Handler =
  | { kind: "json"; parse: (s: string) => unknown }
  | { kind: "text"; parse: (s: string) => string };

type Registry = Record<string, Handler>;

const registry = {
  a: { kind: "json", parse: JSON.parse },
  b: { kind: "text", parse: (s: string) => s },
} satisfies Registry;

const h = registry.a;

if (h.kind === "json") {
  // 여기서 h가 "json" 핸들러로 좁혀지길 기대
  h.parse("{}");
}

이 코드는 환경/구성에 따라 h가 기대만큼 좁혀지지 않거나(특히 registry[key]처럼 동적 인덱싱을 쓰면), h.kind"json" | "text"로 남아서 분기가 무의미해질 수 있습니다.

원인은 satisfies 자체라기보다, Record<string, Handler>라는 컨테이너 타입이 “키마다 서로 다른 변형”을 유지하지 못하고, 결과적으로 값 타입을 Handler 유니온으로 뭉개버리기 때문입니다.

증상 C: string으로 widen되어 리터럴 비교가 의미 없어짐

type Status = "idle" | "loading" | "success" | "error";

const state = {
  status: "loading",
} satisfies { status: Status };

// state.status가 "loading"이 아니라 Status(혹은 string)로 잡히는 경우가 생김

여기서도 관건은 satisfies가 아니라 “리터럴 타입이 widen(확장)되는 조건”입니다. as const가 없고, 객체가 특정 맥락 타입(contextual type)으로 추론되면 리터럴이 넓어질 수 있습니다.

3) 왜 이런 일이 생기나: widen, 컨텍스추얼 타이핑, 인덱싱

TypeScript의 추론을 이해하면 해결책이 선명해집니다.

  1. 리터럴 widen

    • const x = "a"는 보통 "a"
    • 하지만 어떤 맥락 타입이 string이면 "a"string으로 widen될 수 있음
  2. 컨테이너 타입이 정보 손실을 유발

    • Record<string, Handler>는 “각 키마다 다른 구체 타입”을 표현하지 못합니다.
    • 결국 registry.aHandler(유니온)로 보게 되고, 분기 전에는 좁혀지지 않습니다.
  3. 동적 인덱싱은 더 강하게 뭉갠다

    • registry[key]에서 key: string이면 결과는 무조건 Handler
    • key: "a" | "b"라도 결과는 Handler 유니온이 되고, 그 뒤 좁힘은 제한됩니다.

이건 satisfies의 결함이라기보다, “검증 연산자”를 “추론/모델링 도구”로 착각할 때 생기는 전형적인 함정입니다.

4) 해결 레시피 1: as const로 리터럴 고정 + satisfies로 검증

가장 많이 쓰는 조합입니다.

type Status = "idle" | "loading" | "success" | "error";

type State = {
  status: Status;
  retry?: number;
};

const state = {
  status: "loading",
  retry: 3,
} as const satisfies State;

// state.status는 "loading" (리터럴)
// 동시에 State 제약을 만족하는지 검사됨

주의할 점:

  • as const는 객체 전체를 readonly로 만들고 리터럴을 고정합니다.
  • “readonly가 싫다”면 아래 레시피 2/3을 고려하세요.

5) 해결 레시피 2: satisfies로 검증하고, 실제 사용 타입은 별도로 명시

검증과 사용 타입을 분리하는 방식입니다.

type Config = {
  mode: "dev" | "prod";
  endpoint: string;
};

const rawConfig = {
  mode: "dev",
  endpoint: "http://localhost:3000",
  // typo: "endpont" 같은 실수는 satisfies가 잡아줌
} satisfies Config;

// 사용부에서는 명시적으로 Config로 취급
const config: Config = rawConfig;

if (config.mode === "dev") {
  // 여기서는 확실히 좁혀짐
}

이 패턴은 “리터럴을 꼭 유지해야 하는가?”에 따라 장단이 갈립니다.

  • 장점: 사용부 타입이 명확, readonly 문제 없음
  • 단점: 리터럴 유지(예: "dev" 그대로)보다는 Config["mode"]로 보게 됨

6) 해결 레시피 3: defineX() 헬퍼로 추론을 원하는 방향으로 유도

특히 레지스트리/맵에서 키별 타입을 살리고 싶다면, Record<string, ...>로 뭉개기보다 제네릭 헬퍼로 “그대로 추론”하게 만드는 게 좋습니다.

type Handler =
  | { kind: "json"; parse: (s: string) => unknown }
  | { kind: "text"; parse: (s: string) => string };

function defineRegistry<T extends Record<string, Handler>>(r: T) {
  return r;
}

const registry = defineRegistry({
  a: { kind: "json", parse: JSON.parse },
  b: { kind: "text", parse: (s: string) => s },
} satisfies Record<string, Handler>);

const a = registry.a;

if (a.kind === "json") {
  a.parse("{}");
  // a는 { kind: "json"; ... }로 잘 좁혀짐
}

포인트는 다음과 같습니다.

  • defineRegistry의 반환 타입이 T이므로, 각 프로퍼티의 구체 타입이 보존됩니다.
  • satisfies Record<string, Handler>로 “Handler 제약을 만족하는지”는 검증합니다.

이 패턴은 라우팅 테이블, 이벤트 핸들러 맵, 에러 코드 맵 등에서 매우 강력합니다.

7) 해결 레시피 4: 동적 키 인덱싱을 피하거나, 키를 유니온으로 제한

registry[key]에서 key: string이면 어떤 마법을 써도 결과는 Handler 유니온입니다. 해결책은 키를 제한하거나, 키-값 관계를 타입으로 모델링하는 것입니다.

const registry = {
  a: { kind: "json", parse: JSON.parse },
  b: { kind: "text", parse: (s: string) => s },
} as const satisfies Record<string, Handler>;

type Key = keyof typeof registry; // "a" | "b"

function getHandler<K extends Key>(key: K) {
  return registry[key]; // K에 따라 정확한 타입 반환
}

const h = getHandler("a");
if (h.kind === "json") {
  h.parse("{}");
}

핵심은 K extends Key로 키를 좁혀 “인덱싱 결과가 키에 종속”되도록 만드는 것입니다.

8) 해결 레시피 5: satisfies 대신 as가 더 적절한 경우

다음 상황에서는 satisfies가 아니라 as(혹은 명시적 타입 선언)가 더 단순합니다.

  • 런타임에서 값이 이미 신뢰 가능하고(예: 서버에서 스키마 검증 완료)
  • 이후 코드에서 “이 값은 이 타입이다”로 고정해서 다루고 싶을 때
type Payload = { kind: "create"; name: string } | { kind: "delete"; id: string };

declare const fromServer: unknown;

// (예: zod로 검증했다고 가정)
const payload = fromServer as Payload;

if (payload.kind === "delete") {
  payload.id; // 좁힘 OK
}

다만 as는 검증이 아니라 단언이므로, 검증이 필요하면 런타임 스키마(zod, valibot 등)와 함께 쓰는 것이 안전합니다.

9) 실전 체크리스트: “satisfies인데 좁힘이 안 돼요”를 3분 안에 진단

  1. 내가 원하는 건 ‘검증’인가 ‘타입 고정’인가?

    • 고정이면 : T 또는 as T
  2. 리터럴이 widen됐는가?

    • as const 또는 헬퍼 함수로 추론 보존
  3. Record/인덱스 시그니처로 뭉갰는가?

    • Record<string, X>는 정보 손실이 정상
    • defineX<T extends ...>(x: T): T 패턴 고려
  4. 동적 인덱싱을 하고 있는가?

    • keyof typeof obj + 제네릭 K extends Key로 종속 타입 만들기
  5. 분기 기준(discriminant)이 정말 리터럴인가?

    • kind: "json"처럼 리터럴이어야 하고, string이면 분기 불가

(복잡한 타입 이슈는 “재현 가능한 최소 예제”를 만드는 게 가장 빠릅니다. 장애/이슈를 재현해 원인을 좁히는 접근은 인프라/네트워크에서도 동일하게 유효합니다. 예를 들어 LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트처럼 ‘현상 → 재현 → 원인 분리 → 해결’ 순서가 결국 가장 효율적입니다.)

결론

TS 5.x에서 satisfies는 타입 시스템을 더 “정확하게” 쓰게 해주는 도구지만, 역할은 어디까지나 제약 검증입니다. 좁힘이 안 되는 순간은 대부분 satisfies 자체가 아니라 다음 중 하나입니다.

  • 리터럴이 widen됨
  • Record<string, ...> 같은 컨테이너가 타입 정보를 뭉갬
  • 동적 인덱싱으로 유니온이 유지됨

해결은 의외로 단순한 편입니다.

  • 리터럴 고정이 필요하면 as const satisfies ...
  • 키별 타입을 살리고 싶으면 defineX() 같은 제네릭 헬퍼
  • 사용부에서 타입 고정이 목적이면 : T 또는 as T를 명시

이 3가지만 체계적으로 적용해도 “satisfies를 썼는데 왜 타입 좁힘이 안 되지?”라는 혼란은 대부분 사라집니다.