Published on

TS 5.5+ satisfies로 타입추론 깨짐 7가지 해결

Authors

서로 다른 팀/레포에서 as const 대신 satisfies를 표준으로 밀기 시작하면, 처음엔 “타입 검증이 더 좋아졌네”로 끝납니다. 그런데 일정 규모를 넘으면 특정 지점에서 타입 추론이 갑자기 약해지거나, 너무 강해져서(과도하게 좁혀져서) 개발 경험이 깨지는 상황을 자주 만나게 됩니다.

핵심은 이겁니다.

  • satisfies타입을 강제 캐스팅하지 않고, “이 값이 이 타입 조건을 만족하는지”만 검사합니다.
  • 그래서 표현식의 실제 타입은 그대로 유지됩니다.
  • 이 “그대로 유지”가 장점이지만, 동시에 추론 흐름(특히 제네릭, 유니온, 인덱스 접근, 리터럴 보존, 함수 반환 타입)에서 의도와 달라지는 지점이 생깁니다.

아래는 TS 5.5+에서 실무에서 자주 터지는 satisfies 관련 타입추론 이슈 7가지와, 케이스별 해결책입니다.

관련해서 타입 추론이 깨지는 다른 축(데코레이터)도 함께 겪는 경우가 많습니다. 데코레이터 환경이라면 이 글도 같이 보면 맥락이 이어집니다: ES2024 데코레이터로 TS 타입추론 깨질 때


1) satisfies만 쓰고 as const를 빼서 리터럴이 넓어짐

증상

as const를 빼고 satisfies만 사용하면 객체/배열 내부 값이 string/number로 넓어져서, 이후 인덱싱 결과가 기대보다 약해집니다.

type Routes = Record<string, { method: "GET" | "POST"; path: string }>;

const routes = {
  health: { method: "GET", path: "/health" },
  login: { method: "POST", path: "/login" },
} satisfies Routes;

// method가 "GET" | "POST"가 아니라 string으로 흐르는 케이스가 발생할 수 있음
// (특히 중간에 타입 연산/합성이 끼면 리터럴 보존이 깨지기 쉬움)

해결

리터럴 보존이 목적이면 as const와 함께 씁니다.

type Routes = Record<string, { method: "GET" | "POST"; path: string }>;

const routes = {
  health: { method: "GET", path: "/health" },
  login: { method: "POST", path: "/login" },
} as const satisfies Routes;

type RouteKey = keyof typeof routes; // "health" | "login"
  • 규칙: “값을 리터럴로 고정”은 as const, “스펙을 만족하는지 검사”는 satisfies.
  • 둘은 대체재가 아니라 역할이 다릅니다.

2) Record<string, ...>satisfies를 걸어 키 유니온을 잃음

증상

Record<string, T>로 만족 검사하면, 키가 구체 유니온으로 남지 않고 string으로 취급되어 keyof typeof obj가 약해집니다.

type Dict = Record<string, number>;

const points = {
  alice: 10,
  bob: 20,
} satisfies Dict;

type Keys = keyof typeof points; // 기대: "alice" | "bob" / 실제: string으로 약해지는 흐름이 생김

해결 A: as const를 결합해 키를 고정

type Dict = Record<string, number>;

const points = {
  alice: 10,
  bob: 20,
} as const satisfies Dict;

type Keys = keyof typeof points; // "alice" | "bob"

해결 B: Record 대신 satisfies 대상 타입을 더 구체화

type PointKeys = "alice" | "bob";

const points = {
  alice: 10,
  bob: 20,
} satisfies Record<PointKeys, number>;
  • Record<string, ...>는 “어떤 키든 가능”이라는 뜻이라, 타입 시스템이 키를 구체화할 동기가 약합니다.

3) 유니온 스펙에 satisfies를 걸었더니 분기 추론이 안 됨

증상

{ kind: "a" } | { kind: "b" } 같은 태그드 유니온을 만족시키려 satisfies를 쓰면, 이후 코드에서 kind로 좁히는 흐름이 기대보다 둔해질 수 있습니다(특히 중간에 헬퍼 함수/매핑이 섞이면).

type Event =
  | { kind: "click"; x: number; y: number }
  | { kind: "keypress"; key: string };

const e = {
  kind: "click",
  x: 10,
  y: 20,
} satisfies Event;

function handle(ev: Event) {
  if (ev.kind === "click") {
    ev.x; // OK
  }
}

겉으로는 문제 없어 보이지만, 실무에서는 e를 다른 구조로 합성하거나 map/reduce로 감싸면 kind가 넓어져(또는 반대로 과도하게 좁아져) 분기 추론이 흔들립니다.

해결: 유니온을 만족시키는 값은 “명시적 타입”으로 고정하거나, 팩토리 함수를 둔다

type Event =
  | { kind: "click"; x: number; y: number }
  | { kind: "keypress"; key: string };

const clickEvent: Event = { kind: "click", x: 10, y: 20 };

또는 팩토리로 “생성 시점”에만 검증하고, 반환 타입을 안정화합니다.

function makeEvent<T extends Event>(ev: T): T {
  return ev;
}

const e = makeEvent({ kind: "click", x: 10, y: 20 });
  • satisfies는 선언 위치에서만 검사하고, 이후 추론 흐름을 설계해주진 않습니다.

4) 인덱스 접근이 never 또는 과도한 유니온으로 터짐

증상

객체를 satisfies로 검증한 뒤 obj[key] 형태로 접근하면, key의 타입이 넓을 때 never 또는 너무 넓은 유니온이 나옵니다.

const statusText = {
  200: "OK",
  404: "Not Found",
} as const satisfies Record<number, string>;

function getText(code: number) {
  return statusText[code];
  // code가 number라서 "OK" | "Not Found" | undefined 같은 형태로 흔들림
}

해결 A: 키 타입을 좁혀서 받기

type StatusCode = keyof typeof statusText; // 200 | 404

function getText(code: StatusCode) {
  return statusText[code]; // "OK" | "Not Found"
}

해결 B: 런타임 가드 + 타입 가드

function isStatusCode(code: number): code is keyof typeof statusText {
  return code in statusText;
}

function getText(code: number) {
  if (!isStatusCode(code)) return "Unknown";
  return statusText[code];
}
  • satisfies는 “맵의 형태”를 보장하지만, number 같은 넓은 인덱스는 여전히 넓습니다.

5) satisfies로 함수 테이블을 만들었더니 핸들러 파라미터가 any처럼 흐려짐

증상

라우팅/커맨드 테이블에서 각 함수의 시그니처를 유지하고 싶어 satisfies를 붙였는데, 호출부에서 추론이 약해집니다.

type Handlers = {
  ping: (msg: string) => string;
  sum: (a: number, b: number) => number;
};

const handlers = {
  ping: (msg) => msg.toUpperCase(),
  sum: (a, b) => a + b,
} satisfies Handlers;

function call(name: keyof Handlers, ...args: unknown[]) {
  return handlers[name](...args as any);
}

핵심 문제는 callnameargs의 관계를 잃어서, 결국 any 캐스팅이 들어가며 타입 안정성이 무너집니다.

해결: 제네릭으로 name과 파라미터/리턴을 연결

type Handlers = {
  ping: (msg: string) => string;
  sum: (a: number, b: number) => number;
};

const handlers = {
  ping: (msg: string) => msg.toUpperCase(),
  sum: (a: number, b: number) => a + b,
} satisfies Handlers;

function call<K extends keyof Handlers>(
  name: K,
  ...args: Parameters<Handlers[K]>
): ReturnType<Handlers[K]> {
  return handlers[name](...args);
}

call("ping", "hi");
call("sum", 1, 2);
// call("sum", "1", "2"); // 컴파일 에러
  • satisfies는 “테이블이 Handlers를 만족”하는지 보지만, 호출 API는 별도로 “관계형 제네릭”을 설계해야 합니다.

6) satisfies가 과도한 리터럴 고정을 유발해 재사용성이 떨어짐

증상

as const satisfies ...를 남발하면 값이 지나치게 좁아져, 이후 함수가 일반적인 string/number를 기대할 때 오히려 타입 호환이 깨집니다.

const config = {
  env: "prod",
  region: "ap-northeast-2",
} as const satisfies { env: string; region: string };

function setEnv(env: string) {}
setEnv(config.env); // "prod"는 string에 할당 가능이라 보통 OK지만,
// 제네릭/조건부 타입이 섞이면 "prod"가 너무 좁아서 역으로 문제가 되는 케이스가 생김

해결: “외부로 노출할 타입”과 “내부 리터럴”을 분리

  • 내부에서는 리터럴을 유지
  • 외부 API 경계에서는 widen(확장)된 타입으로 변환
const config = {
  env: "prod",
  region: "ap-northeast-2",
} as const;

type Config = { env: string; region: string };
const publicConfig: Config = config; // 여기서 넓힘

또는 필요한 필드만 넓히는 유틸을 둡니다.

type Widen<T> = { [K in keyof T]: T[K] extends string ? string : T[K] };

type Public = Widen<typeof config>;
  • satisfies는 검증 도구이고, “노출 타입 설계”는 별개의 문제입니다.

7) satisfiesPartial/Required 조합에서 선택 속성 추론이 꼬임

증상

설정 오버라이드 패턴에서 Partial을 만족시키려 satisfies를 붙이면, 병합 이후 타입이 기대보다 undefined를 많이 품거나, 반대로 필수화가 안 됩니다.

type Options = {
  timeoutMs: number;
  retries: number;
  baseUrl: string;
};

const defaults = {
  timeoutMs: 3000,
  retries: 2,
  baseUrl: "https://api.example.com",
} as const satisfies Options;

const override = {
  retries: 5,
} satisfies Partial<Options>;

const merged = { ...defaults, ...override };
// merged.retries는 2 | 5 같은 리터럴 유니온으로 남거나,
// 다른 필드는 readonly/리터럴이 섞여 후속 연산에서 불편해질 수 있음

해결 A: 병합 결과 타입을 명시적으로 Options로 안정화

const merged: Options = { ...defaults, ...override };

해결 B: 병합 함수를 만들고 반환 타입을 고정

function mergeOptions(
  base: Options,
  over: Partial<Options>
): Options {
  return { ...base, ...over };
}

const merged = mergeOptions(defaults, override);

해결 C: satisfies 대상 타입을 Partial이 아니라 “정확한 스키마”로

Partial은 “없어도 된다”를 의미할 뿐, “있으면 이 타입이어야 한다”는 의미가 약해지기 쉽습니다. 오버라이드에서 실제로 허용할 키만 제한하는 편이 안전합니다.

type Override = Partial<Pick<Options, "timeoutMs" | "retries">>;

const override = { retries: 5 } satisfies Override;

실전 체크리스트: satisfies를 쓸 때 추론을 지키는 규칙

  1. 리터럴을 보존해야 하면 as const satisfies ... 조합을 고려한다.
  2. 키 유니온이 중요하면 Record<string, ...>를 그대로 두지 말고 키를 구체화한다.
  3. 태그드 유니온은 값 선언에서 satisfies만 믿지 말고, 팩토리/명시 타입으로 안정화한다.
  4. 인덱스 접근은 키 타입을 좁히거나 타입 가드를 붙인다.
  5. 함수 테이블은 호출 API에서 K extends keyof ...로 파라미터/리턴을 연결한다.
  6. 리터럴이 너무 좁아 재사용성이 깨지면 “내부 리터럴”과 “외부 노출 타입”을 분리한다.
  7. Partial 병합 결과는 반환 타입을 명시해 undefined 전염과 리터럴 유니온 폭발을 막는다.

보너스: satisfies를 안전하게 쓰는 템플릿 2가지

템플릿 A: 객체 스펙 검증 + 키/리터럴 보존

type Spec = {
  readonly version: number;
  readonly features: Record<string, boolean>;
};

const spec = {
  version: 1,
  features: {
    darkMode: true,
    beta: false,
  },
} as const satisfies Spec;

type FeatureKey = keyof typeof spec.features; // "darkMode" | "beta"

템플릿 B: 테이블 + 타입 안전 호출

type Api = {
  getUser: (id: string) => Promise<{ id: string; name: string }>;
  deleteUser: (id: string) => Promise<void>;
};

const api = {
  getUser: async (id: string) => ({ id, name: "n" }),
  deleteUser: async (id: string) => {},
} satisfies Api;

async function call<K extends keyof Api>(
  key: K,
  ...args: Parameters<Api[K]>
): Promise<Awaited<ReturnType<Api[K]>>> {
  return api[key](...args) as any;
}

마지막 as any는 TS가 Promise/Awaited 조합에서 보수적으로 굴 때만 제한적으로 쓰고, 가능하면 제거하는 방향으로 조정하세요.


마무리

TS 5.5+에서 satisfies는 “캐스팅 없는 스펙 검증”이라는 점에서 매우 유용하지만, 추론이 자동으로 좋아지는 기능은 아닙니다. 특히 Record, 인덱스 접근, 유니온, 병합(Partial), 함수 테이블 같은 패턴에서는 “검증”과 “추론 설계”를 분리해서 생각해야 타입이 안정적으로 유지됩니다.

데코레이터까지 같이 쓰는 프로젝트라면, 추론이 깨지는 원인이 satisfies인지 데코레이터 변환인지 섞여 보이기도 합니다. 그 경우에는 TS 5.5+ Decorators 적용 시 타입오류 6가지도 함께 확인하면 디버깅 시간이 확 줄어듭니다.