Published on

TS 5.5+ satisfies로 타입 단언 없이 오류 잡기

Authors

서론

TypeScript를 쓰다 보면 “일단 맞겠지”라는 마음으로 as 타입 단언을 붙이는 순간이 자주 옵니다. 특히 객체 리터럴(설정, 라우트 테이블, 이벤트 핸들러 맵, i18n 메시지, 권한 매트릭스) 같은 곳에서 타입이 조금만 복잡해져도 as SomeType로 밀어붙이기 쉽습니다. 문제는 그 다음입니다.

  • 단언은 컴파일러의 검증을 끊어버려 런타임 오류를 숨깁니다.
  • 리팩터링(키 변경, 필드 추가/삭제) 시 깨진 지점을 컴파일 타임에 놓치기 쉽습니다.
  • “타입은 통과했는데 실제 값은 이상한” 상태가 생깁니다.

TS 5.5+ 환경에서 이런 문제를 줄이는 데 가장 실용적인 도구가 satisfies입니다. satisfies는 “이 값이 특정 타입의 조건을 만족하는지”만 검사하고, 값의 구체적인 타입(리터럴 타입)은 최대한 보존합니다. 즉, as처럼 타입을 강제로 바꾸지 않고도 오류를 잡고, 추론은 유지할 수 있습니다.

이 글에서는 satisfies를 왜 써야 하는지, 그리고 실무에서 자주 쓰는 패턴(설정 객체, 매핑 테이블, 핸들러 레지스트리, 문자열 리터럴 유지 등)에 어떻게 적용하는지 코드로 정리합니다.

as 단언이 만드는 “조용한 실패”

먼저 단언이 왜 위험한지부터 짚고 가겠습니다.

type RetryPolicy = {
  maxRetries: number;
  baseDelayMs: number;
  jitter: "none" | "full";
};

// 나쁜 예: 단언으로 컴파일러를 속임
const policy = {
  maxRetries: "5",      // 문자열인데도
  baseDelayMs: 200,
  jitter: "FULL",       // 잘못된 값인데도
} as RetryPolicy;

// 컴파일은 되지만 런타임에서 문제가 터질 수 있음

as RetryPolicy는 “이 객체를 RetryPolicy로 취급해”라고 강제합니다. 즉, 검증이 아니라 강제 캐스팅입니다. 이런 종류의 버그는 API 재시도/백오프 같은 로직에서 특히 치명적인데, 재시도 설계 자체가 민감한 영역이라 더더욱 컴파일 타임에서 잡아야 합니다. (관련해서는 OpenAI 429·insufficient_quota 재시도와 백오프 설계 같은 글을 함께 보면 “설정값 안정성”의 중요성이 더 와닿습니다.)

satisfies의 핵심: “검사하되, 타입은 보존”

satisfies는 다음 두 가지를 동시에 만족시키는 데 강점이 있습니다.

  1. 해당 값이 특정 타입을 만족하는지 검사한다.
  2. 값의 구체적인 타입(리터럴/좁은 타입)을 그대로 유지한다.
type RetryPolicy = {
  maxRetries: number;
  baseDelayMs: number;
  jitter: "none" | "full";
};

const policy = {
  maxRetries: 5,
  baseDelayMs: 200,
  jitter: "full",
} satisfies RetryPolicy;

// policy.jitter는 "full" 리터럴로 유지될 수 있음

여기서 중요한 차이:

  • const policy: RetryPolicy = {...} 처럼 타입 주석(annotation) 을 붙이면, 객체는 RetryPolicy로 “넓혀져(widen)” 리터럴 정보가 일부 사라질 수 있습니다.
  • satisfies RetryPolicy는 “맞는지 검사만” 하고, policy 자체의 추론은 최대한 유지합니다.

언제 : Type보다 satisfies가 더 좋은가?

1) 키/값 매핑 테이블에서 리터럴을 유지하고 싶을 때

예를 들어 상태 코드별 메시지 매핑을 만든다고 해봅시다.

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

const statusText: Record<Status, string> = {
  idle: "대기",
  loading: "로딩 중",
  success: "완료",
  error: "실패",
};

이 코드는 충분히 좋아 보이지만, statusText.idle의 타입은 그냥 string입니다. 어떤 경우에는 이를 더 좁게 유지하고 싶습니다(예: UI 테스트 스냅샷, 분기 최적화, 특정 값 기반의 추가 매핑 등).

const statusText = {
  idle: "대기",
  loading: "로딩 중",
  success: "완료",
  error: "실패",
} satisfies Record<Status, string>;

// 여전히 Status 키를 모두 강제하면서
// 값은 가능한 한 리터럴로 보존되는 방향으로 추론됨

추가로, 키를 빠뜨리거나 오타를 내면 즉시 잡힙니다.

const statusText = {
  idle: "대기",
  loading: "로딩 중",
  success: "완료",
  // error 누락
} satisfies Record<Status, string>;
//    ^ 컴파일 에러: 'error'가 없음

2) “허용된 키만” 받는 설정 객체

웹앱에서 자주 쓰는 패턴이 “config 객체”입니다. 문제는 config가 커질수록 실수로 잘못된 키를 넣어도 놓치기 쉽다는 점입니다.

type AppConfig = {
  apiBaseUrl: string;
  enableTelemetry: boolean;
  cacheTtlSec?: number;
};

const config = {
  apiBaseUrl: "https://api.example.com",
  enableTelemetry: true,
  cacheTTL: 60, // 오타: cacheTtlSec 이어야 함
} satisfies AppConfig;
//    ^ 컴파일 에러: 'cacheTTL'은 AppConfig에 없음

만약 여기서 as AppConfig를 썼다면? 오타가 그대로 통과합니다.

3) 핸들러/라우트 레지스트리에서 시그니처 오류를 잡기

이벤트 핸들러 테이블을 만들 때 satisfies는 특히 강력합니다.

type Events = {
  "user.created": { id: string; email: string };
  "user.deleted": { id: string };
};

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

const handlers = {
  "user.created": async (payload) => {
    payload.email.toLowerCase();
  },
  "user.deleted": async (payload) => {
    payload.email; // Property 'email' does not exist
  },
} satisfies HandlerMap;
  • 모든 이벤트 키가 존재해야 하고
  • 각 핸들러의 입력 payload 타입이 정확해야 하며
  • 객체 자체는 최대한 구체적으로 유지됩니다.

이 패턴은 “런타임에 특정 키로 핸들러를 찾는” 구조에서 컴파일 타임 안정성을 크게 올립니다.

TS 5.5+에서 더 체감이 좋은 이유

satisfies 자체는 TS 4.9에서 도입됐지만, TS 5.x로 오면서 전반적인 타입 추론/좁히기/에러 메시지 품질이 좋아졌고, 실무 코드베이스(특히 noUncheckedIndexedAccess, exactOptionalPropertyTypes 같은 옵션을 켠 프로젝트)에서 satisfies의 효용이 더 커졌습니다.

예를 들어 선택적 프로퍼티가 섞인 설정에서 “있어도 되고 없어도 되는 값”을 다루다 보면, 단언으로 얼버무리기 쉬운데 satisfies로 모델을 고정해두면 변경 시점에 에러로 바로 드러납니다.

satisfies를 잘 쓰는 실전 패턴 5가지

1) as const + satisfies 조합으로 “값 고정 + 타입 검증”

리터럴을 강하게 고정하고 싶을 때 as const를 붙이곤 합니다. 여기에 satisfies를 더하면 “고정된 값이 특정 스펙을 만족하는지”까지 확인할 수 있습니다.

type FeatureFlags = {
  newCheckout: boolean;
  betaSearch: boolean;
};

const flags = {
  newCheckout: true,
  betaSearch: false,
} as const satisfies FeatureFlags;

// flags.newCheckout 타입은 true (리터럴)
// 동시에 FeatureFlags 형태를 만족해야 함

2) Record<string, ...> 대신 “정확한 키 집합”을 강제

아래처럼 “문자열 키 아무거나”를 허용하면 오타가 숨어들기 쉽습니다.

const messages: Record<string, string> = {
  login: "로그인",
  logni: "(오타)",
};

키 집합을 유니온으로 만들고 satisfies로 강제하세요.

type MessageKey = "login" | "logout";

const messages = {
  login: "로그인",
  logout: "로그아웃",
  // logni: "오타" // 즉시 에러
} satisfies Record<MessageKey, string>;

3) “최소 요구사항”만 강제하고 나머지는 자유롭게

satisfies는 구조적 타이핑이므로, 특정 필드만 강제하는 데도 유용합니다.

type HasId = { id: string };

const user = {
  id: "u_123",
  email: "a@b.com",
  roles: ["admin"],
} satisfies HasId;

// user는 id/email/roles를 모두 가진 구체 타입으로 남음

4) Map/Set 초기화 데이터 검증

Map을 만들 때도 “원천 데이터 배열”을 satisfies로 검증하면 안전합니다.

type CountryCode = "KR" | "US" | "JP";

const entries = [
  ["KR", "대한민국"],
  ["US", "United States"],
  // ["UK", "United Kingdom"], // 에러로 잡고 싶다
] satisfies Array<[CountryCode, string]>;

const countryMap = new Map(entries);

5) 외부 입력(환경변수/JSON)을 “검증된 결과”로 분리

중요: satisfies런타임 검증 도구가 아닙니다. 환경변수나 JSON 파싱 결과는 런타임에서 검증해야 합니다.

하지만 런타임 검증을 한 뒤 “검증된 결과를 담는 객체”를 만들 때 satisfies가 큰 도움이 됩니다.

type Env = {
  NODE_ENV: "development" | "production";
  API_BASE_URL: string;
};

function parseEnv(raw: Record<string, string | undefined>): Env {
  const NODE_ENV = raw.NODE_ENV === "production" ? "production" : "development";
  const API_BASE_URL = raw.API_BASE_URL ?? "http://localhost:3000";

  // 반환 직전에 스펙 만족 여부를 컴파일 타임에 고정
  const env = {
    NODE_ENV,
    API_BASE_URL,
  } satisfies Env;

  return env;
}

이렇게 하면 필드 누락/오타/타입 불일치가 함수 내부에서 즉시 드러납니다.

자주 하는 실수와 주의점

1) satisfies는 타입을 “바꿔주지” 않는다

satisfies는 검사만 합니다. 따라서 아래처럼 “타입을 Env로 만들었으니 안전하겠지”는 착각입니다.

const raw = { NODE_ENV: "prod" };
const env = raw satisfies { NODE_ENV: "production" | "development" };
// env.NODE_ENV는 여전히 "prod"일 수 있음 (검사에서 걸려야 함)

즉, satisfies검증 실패 시 에러를 내는 장치이지, 값을 변환해주지 않습니다.

2) 런타임 데이터에는 Zod/io-ts 같은 검증이 필요

API 응답/로컬스토리지/환경변수처럼 런타임에서 들어오는 값은 satisfies로는 안전해지지 않습니다. 이때는 런타임 스키마 검증을 하고, 그 결과를 타입으로 연결하는 방식이 필요합니다.

다만 “검증 후 구성한 결과 객체”에 satisfies를 걸어두면, 스키마/타입 정의가 바뀌었을 때 컴파일 타임에 누락을 잡는 데 매우 좋습니다.

satisfies를 도입하기 좋은 코드 냄새 체크리스트

아래 중 하나라도 해당하면 satisfies 도입을 고려할 가치가 큽니다.

  • 설정 객체에 as SomeConfig가 붙어 있다
  • 라우트/이벤트/권한 테이블이 Record<string, ...>로 뭉뚱그려져 있다
  • 객체 리터럴에서 오타로 인한 장애가 종종 발생한다
  • 리팩터링 시 “컴파일은 되는데 런타임에서 깨지는” 일이 있다
  • as const를 남발하지만 스펙 검증이 없다

특히 프론트엔드에서는 SSR/CSR 경계에서 데이터 형태가 조금만 어긋나도 오류가 커지는데, 이런 “형태 불일치”는 가능한 한 컴파일 타임에 앞당겨 잡는 게 좋습니다. 관련해서 렌더링 단계에서의 불일치 문제를 다룬 Next.js Hydration Mismatch 5가지 원인과 해결법도 함께 참고하면, “초기에 잡는 타입/형태 검증”이 왜 중요한지 맥락이 이어집니다.

결론

TS 5.5+에서 satisfies는 “타입 단언을 줄이면서도 더 많은 오류를 잡는” 가장 가성비 좋은 도구입니다. 핵심은 간단합니다.

  • as는 컴파일러를 무력화한다.
  • satisfies는 컴파일러로 하여금 검사하게 하되, 값의 추론은 보존한다.

설정 객체, 매핑 테이블, 핸들러 레지스트리처럼 객체 리터럴이 곧 스펙인 코드에서 satisfies를 표준으로 삼아보세요. 리팩터링 내성이 올라가고, “조용히 통과하는 버그”가 눈에 띄게 줄어듭니다.