Published on

TS 5.x satisfies로 타입 검증·추론 둘 다 잡기

Authors

서버 설정, 라우팅 테이블, 이벤트 스키마, 권한 매핑처럼 "객체 리터럴"로 선언해두고 오래 쓰는 데이터는 TypeScript에서 두 가지 요구가 동시에 생깁니다.

  • 컴파일 타임에 스키마가 맞는지 강하게 검증하고 싶다
  • 동시에 값의 구체적인 타입(리터럴, 좁은 유니온, 키 집합)을 잃지 않고 추론에 활용하고 싶다

기존에는 보통 as SomeType 단언으로 "검증"을 흉내 냈는데, 단언은 말 그대로 "내가 맞다고 할 테니 믿어"라서 타입 안전을 떨어뜨리기 쉽습니다. 반대로 const x: SomeType = ...처럼 타입 주석을 달면 검증은 되지만 추론이 넓어져서, 키/값 리터럴 정보가 사라져 downstream 타입이 약해지는 경우가 많습니다.

TypeScript 4.9부터 도입되고 5.x에서도 널리 쓰이는 satisfies는 이 딜레마를 깔끔하게 해결합니다. 핵심은 다음 한 줄입니다.

  • satisfies는 "해당 타입을 만족하는지"만 검사하고, "표현식의 타입"은 최대한 그대로 유지한다

as 단언 vs 타입 주석 vs satisfies

세 방식의 차이를 작은 예제로 비교해보겠습니다.

type Env = "dev" | "prod";

type Config = {
  env: Env;
  retry: number;
  endpoints: Record<string, string>;
};

// 1) 타입 주석: 검증은 되지만, 값이 넓어지기 쉬움
const config1: Config = {
  env: "dev",
  retry: 3,
  endpoints: {
    user: "/api/user",
    billing: "/api/billing",
  },
};

// 2) 단언: 검증을 스킵할 수 있어 위험
const config2 = {
  env: "dev",
  retry: "3", // 실수지만...
  endpoints: {
    user: "/api/user",
  },
} as unknown as Config; // 강제로 통과

// 3) satisfies: Config를 만족하는지 검사하면서, 객체 자체의 추론은 보존
const config3 = {
  env: "dev",
  retry: 3,
  endpoints: {
    user: "/api/user",
    billing: "/api/billing",
  },
} satisfies Config;

config3Config에 맞는지 엄격히 검사됩니다. 동시에 config3.endpoints의 키가 "user" | "billing" 같은 형태로 더 구체적으로 남아, 이후 타입 계산에 유리해집니다.

이 특성이 특히 빛나는 지점은 "키를 기반으로 타입을 파생"할 때입니다.

객체 리터럴에서 키 유니온을 잃지 않기

라우트 테이블, 이벤트 핸들러 맵, 권한 스코프 맵 같은 코드는 흔히 이렇게 작성합니다.

type Handler = (payload: unknown) => Promise<void>;

type HandlerMap = Record<string, Handler>;

const handlers: HandlerMap = {
  "user.created": async (payload) => {},
  "billing.paid": async (payload) => {},
};

type EventName = keyof typeof handlers; // string 으로 넓어지기 쉬움

Record<string, ...>로 주석을 달아버리면 keyofstring으로 넓어져, 이벤트 이름을 타입으로 활용하기 어려워집니다.

Satisfies를 쓰면 검증과 추론을 동시에 잡습니다.

type Handler = (payload: unknown) => Promise<void>;

type HandlerMap = Record<string, Handler>;

const handlers = {
  "user.created": async (payload) => {},
  "billing.paid": async (payload) => {},
} satisfies HandlerMap;

type EventName = keyof typeof handlers;
// "user.created" | "billing.paid"

이제 EventName은 실제 키들의 유니온으로 유지됩니다. 이벤트 발행 API를 타입 안전하게 만들 수 있습니다.

async function publish(event: EventName, payload: unknown) {
  await handlers[event](payload);
}

publish("user.created", { id: 1 });
// publish("user.deleted", {})  // 컴파일 에러

값 리터럴을 보존하면서 스키마 검증하기

설정 객체에서 값도 리터럴로 보존되면, 분기 로직에서 타입이 더 똑똑해집니다.

예를 들어 배포 환경별로 기능 플래그를 제어한다고 해봅시다.

type FeatureFlags = {
  enableNewCheckout: boolean;
  enableRscCache: boolean;
};

type EnvConfig = {
  env: "dev" | "staging" | "prod";
  flags: FeatureFlags;
};

const envConfig = {
  env: "prod",
  flags: {
    enableNewCheckout: true,
    enableRscCache: false,
  },
} satisfies EnvConfig;

여기서 중요한 점은 envConfig.env가 단순히 "dev" | "staging" | "prod"로만 남는 게 아니라, 실제 값인 "prod"로 더 좁게 유지될 수 있다는 것입니다(상황에 따라 컨텍스트가 달라질 수 있지만, 타입 주석을 직접 부여하는 것보다 추론 보존에 유리합니다).

이 패턴은 Next.js 설정, API 클라이언트 엔드포인트 테이블, 권한 스코프 정의처럼 "값이 곧 규칙"인 코드에서 특히 효과적입니다. Next.js 관련 캐시 이슈를 다룬 글과도 연결되는 맥락인데, RSC 캐시 정책을 코드로 모델링할 때 키/값 리터럴 보존이 디버깅과 리팩터링에 큰 도움이 됩니다.

satisfies로 "필수 키 누락"과 "오타"를 컴파일 타임에 잡기

Record나 인덱스 시그니처를 쓸 때는 키 오타가 조용히 지나가기 쉽습니다. 반면 명시적인 키 집합을 타입으로 만들고 satisfies로 검증하면 누락/오타가 바로 드러납니다.

type Scope = "read:user" | "write:user" | "read:billing";

type ScopeToDescription = {
  [K in Scope]: string;
};

const scopeDesc = {
  "read:user": "사용자 조회",
  "write:user": "사용자 수정",
  "read:billing": "결제 조회",
  // "read:billling": "오타"  // 있으면 에러
  // "write:billing" 누락도 에러
} satisfies ScopeToDescription;

이 방식은 보안 설정처럼 "실수 비용"이 큰 영역에서 특히 유용합니다. 예를 들어 JWT 클레임이나 권한 스코프를 다룰 때, 문자열 오타 하나가 401/403으로 이어지기도 합니다.

"검증은 강하게, 사용은 유연하게": API 스키마 테이블 패턴

백엔드와 프론트가 공유하는 API 스키마(요청/응답 형태)를 테이블로 들고 있고, 여기서 타입을 뽑아 쓰는 패턴을 자주 씁니다. 이때 satisfies는 테이블 자체의 정확성을 보장하면서도, 각 엔드포인트의 구체 타입을 잃지 않게 해줍니다.

아래 예시는 런타임 검증 라이브러리 없이도, 최소한의 정적 안전성을 확보하는 예입니다.

type ApiSpec = {
  path: string;
  method: "GET" | "POST" | "PUT" | "DELETE";
  // 실제로는 zod 같은 런타임 스키마를 넣기도 함
  request: unknown;
  response: unknown;
};

type ApiTable = Record<string, ApiSpec>;

const api = {
  getUser: {
    path: "/api/user",
    method: "GET",
    request: undefined,
    response: { id: 0, name: "" },
  },
  pay: {
    path: "/api/billing/pay",
    method: "POST",
    request: { amount: 0 },
    response: { ok: true },
  },
} satisfies ApiTable;

type ApiName = keyof typeof api;

type ResponseOf<N extends ApiName> = typeof api[N]["response"];

function callApi<N extends ApiName>(name: N): Promise<ResponseOf<N>> {
  // 구현은 생략
  return Promise.resolve(api[name].response as ResponseOf<N>);
}

async function demo() {
  const r = await callApi("pay");
  // r은 { ok: true } 형태로 추론됨
}

여기서 만약 method"PATCH"를 실수로 넣거나 path를 빼먹으면 satisfies ApiTable에서 즉시 에러가 납니다. 하지만 api.pay.response 같은 값의 구체적인 형태는 그대로 남아서 제네릭 추론에 활용됩니다.

satisfiesas const의 역할 분담

둘을 같이 쓰는 경우가 많지만 목적이 다릅니다.

  • as const는 리터럴을 최대한 좁히고 readonly로 고정한다
  • satisfies는 특정 타입 조건을 만족하는지 검사한다

예를 들어 HTTP 상태 코드 메시지 테이블을 만들 때:

type Status = 200 | 400 | 401 | 403 | 500;

type StatusMessage = {
  [K in Status]: string;
};

const messages = {
  200: "OK",
  400: "Bad Request",
  401: "Unauthorized",
  403: "Forbidden",
  500: "Internal Server Error",
} as const satisfies StatusMessage;

여기서 as const는 값들을 리터럴로 잠그고, satisfies는 키 누락/오타를 잡습니다. 단, as const를 남발하면 객체가 전부 readonly가 되어 변경이 필요한 코드에서 불편할 수 있으니, "정적 테이블"에만 적용하는 것이 좋습니다.

실전에서 자주 하는 실수와 체크리스트

1) satisfies를 "타입 변환"으로 착각하기

expr satisfies Texpr의 타입을 T로 바꾸지 않습니다. 오직 "검사"만 합니다. 따라서 아래처럼 T로 다루고 싶다면 별도의 타입 가드나 함수 제네릭 설계가 필요합니다.

type Config = { retry: number };

const x = { retry: 3 } satisfies Config;
// x의 타입이 Config로 "고정"되는 것은 아님

이게 오히려 장점인 이유는, 불필요하게 타입을 넓혀버리지 않기 때문입니다.

2) 인덱스 시그니처를 너무 넓게 잡기

Record<string, ...>는 검증에는 좋지만 키 유니온을 넓히는 원인이 됩니다. 가능한 경우 키 집합을 유니온으로 정의하고, mapped type으로 강제하는 편이 더 안전합니다.

3) 객체를 함수 인자로 넘길 때는 "함수 레벨"에서도 검증하기

테이블 선언에서만 satisfies를 쓰고 끝내기보다, 외부 입력을 받는 함수에서는 제네릭 제약으로 한 번 더 방어하는 게 좋습니다.

type Rule = { name: string; enabled: boolean };

type RuleMap = Record<string, Rule>;

function registerRules<T extends RuleMap>(rules: T) {
  return rules;
}

const rules = registerRules({
  a: { name: "A", enabled: true },
  b: { name: "B", enabled: false },
} satisfies RuleMap);

이렇게 하면 선언부에서도 검증하고, 사용부에서도 타입 추론을 보존합니다.

마이그레이션 전략: assatisfies로 바꾸는 순서

레거시 코드에서 as 단언이 많다면 한 번에 다 바꾸기보다, "정적 테이블"부터 바꾸는 게 효과 대비 리스크가 낮습니다.

  1. 라우트/이벤트/권한/상태코드 같은 상수 테이블에서 as SomeTypesatisfies SomeType로 교체
  2. 교체 후 발생하는 타입 에러는 실제 버그일 확률이 높으니 우선 수정
  3. 그 다음 설정 객체, 스키마 레지스트리, 핸들러 맵 등으로 확장

이 과정은 CI에서 타입 체크의 신뢰도를 올리는 데도 도움이 됩니다. 타입 안정성이 올라가면 빌드 파이프라인에서 "나중에 터지는" 문제를 더 앞단에서 잡게 되고, 캐시나 의존성 문제처럼 다른 이슈를 추적할 때도 변수가 줄어듭니다.

정리

Satisfies는 "타입 검증"과 "타입 추론 보존"을 동시에 만족시키는, TS 5.x 코드베이스에서 가장 비용 대비 효과가 큰 기능 중 하나입니다.

  • as 단언을 줄이고 실제 검증을 컴파일러에 맡길 수 있다
  • 객체 리터럴의 키/값 리터럴 정보가 유지되어 downstream 타입이 강해진다
  • 라우팅, 이벤트, 권한, 설정 같은 정적 테이블에서 특히 강력하다

결론적으로, 타입 주석으로 인해 추론이 무뎌지는 문제를 겪고 있다면 satisfies를 "검증용"으로 도입하고, 추론은 TypeScript가 하게 두는 방향이 가장 깔끔합니다.