Published on

TS 5.x satisfies로 타입 안전 유지하며 객체 검증

Authors

서론

TypeScript로 설정 객체(config), 라우팅 테이블, 이벤트 핸들러 맵, 권한 정책처럼 “키-값으로 의미를 담는 객체”를 만들다 보면 늘 같은 딜레마가 생깁니다.

  • 검증(Validation): 객체가 특정 스키마(필수 키/값 타입)를 만족하는지 컴파일 타임에 확인하고 싶다.
  • 추론(Inference): 동시에, 객체 리터럴의 구체적인 리터럴 타입(예: 'prod', '/health', 2000)을 잃고 싶지 않다.

전통적으로는 : SomeType 주석(annotation)이나 as SomeType 단언(assertion)으로 해결했지만, 이 방식은 종종 추론을 망치거나(너무 넓어짐) 타입 안전을 약화시킵니다(잘못된 값도 통과).

TypeScript 4.9부터 도입되어 5.x에서 사실상 표준 도구가 된 satisfies 연산자는 이 문제를 상당히 깔끔하게 해결합니다. 핵심은 한 문장으로 요약됩니다.

> satisfies는 “이 값이 어떤 타입을 만족하는지”만 검사하고, 값 자체의 추론 타입은 최대한 유지한다.

이 글에서는 satisfies를 이용해 객체를 검증하면서 타입 안전을 유지하는 실전 패턴을 정리합니다.

satisfies가 해결하는 문제: 주석 vs 단언 vs satisfies

1) 타입 주석(: Type)의 함정: 추론 타입이 넓어진다

type Env = 'dev' | 'prod';

type AppConfig = {
  env: Env;
  port: number;
};

// 타입 주석을 붙이면 객체 전체가 AppConfig로 "맞춰"집니다.
const config: AppConfig = {
  env: 'prod',
  port: 3000,
};

// config.env의 타입은 Env (즉 'dev' | 'prod')
// 리터럴 'prod'를 잃었습니다.

config.env'prod'라는 사실을 알고 싶을 때(조건 분기, 맵 키, 로깅 태그 등) 추론이 넓어져 불편해집니다.

2) 타입 단언(as Type)의 함정: 검증이 약해진다

type AppConfig = {
  env: 'dev' | 'prod';
  port: number;
};

// 단언은 "검증"이 아니라 "우기기"입니다.
const config = {
  env: 'production', // 오타
  port: '3000',      // 문자열
} as AppConfig;

// 컴파일이 통과할 수 있어 런타임 사고로 이어집니다.

3) satisfies: 검증은 하되, 추론은 유지

type AppConfig = {
  env: 'dev' | 'prod';
  port: number;
};

const config = {
  env: 'prod',
  port: 3000,
} satisfies AppConfig;

// 검증: AppConfig 규칙을 만족해야 함
// 추론: config.env는 'prod' (리터럴 유지), config.port는 3000 (리터럴 유지)

여기서 중요한 포인트:

  • satisfies AppConfig오류를 잡는 용도입니다.
  • config의 타입은 AppConfig로 “변환”되지 않고, 객체 리터럴의 추론 타입이 유지됩니다.

객체 검증 패턴 1: “필수 키는 강제, 값은 리터럴 유지”

설정 객체를 만들 때 가장 흔한 요구는 “필수 키가 빠지면 안 되고, 값 타입도 맞아야 한다”입니다.

type FeatureFlags = {
  enableNewUI: boolean;
  enableBetaFlow: boolean;
};

type Config = {
  env: 'dev' | 'stage' | 'prod';
  apiBaseUrl: string;
  flags: FeatureFlags;
};

const config = {
  env: 'prod',
  apiBaseUrl: 'https://api.example.com',
  flags: {
    enableNewUI: true,
    enableBetaFlow: false,
  },
} satisfies Config;

// config.env: 'prod'
// config.flags.enableNewUI: true

이 상태에서 키를 빼거나 타입을 틀리면 즉시 컴파일 에러가 납니다.

const badConfig = {
  env: 'prod',
  // apiBaseUrl 누락
  flags: {
    enableNewUI: true,
    enableBetaFlow: false,
  },
} satisfies Config; // ❌ Property 'apiBaseUrl' is missing

객체 검증 패턴 2: “키 집합을 제한”하는 맵/테이블

라우팅 테이블, 상태 코드 메시지 맵, 권한 정책처럼 “키의 집합”이 중요한 객체가 있습니다.

예를 들어 API 엔드포인트별 타임아웃을 강제한다고 해봅시다.

type Endpoint = 'health' | 'login' | 'checkout';

type TimeoutTable = Record<Endpoint, number>;

const timeouts = {
  health: 500,
  login: 2000,
  checkout: 5000,
} satisfies TimeoutTable;

// timeouts.health 타입은 500 (리터럴), 하지만 Endpoint 키 누락/추가를 잡아줌

키를 누락하면 에러:

const timeouts2 = {
  health: 500,
  login: 2000,
  // checkout 누락
} satisfies TimeoutTable; // ❌ Property 'checkout' is missing

불필요한 키를 추가하면 에러(초과 속성 검사):

const timeouts3 = {
  health: 500,
  login: 2000,
  checkout: 5000,
  admin: 9999,
} satisfies TimeoutTable; // ❌ Object literal may only specify known properties

이 패턴은 운영 환경에서 “정의되지 않은 경로/상태가 조용히 빠져서 장애가 나는” 문제를 예방합니다. 실전에서 장애 대응/체크리스트를 체계화하는 관점은 OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커 같은 글에서 다루는 방식과도 결이 같습니다. 즉, 실패 케이스를 타입 레벨에서 미리 닫아두는 접근입니다.

객체 검증 패턴 3: 유니온 기반 “핸들러 맵”을 안전하게 만들기

이벤트 타입별 핸들러를 객체로 만들 때 satisfies는 특히 유용합니다.

type Event =
  | { type: 'USER_CREATED'; payload: { id: string } }
  | { type: 'USER_DELETED'; payload: { id: string } };

type EventType = Event['type'];

type HandlerMap = {
  [K in EventType]: (event: Extract<Event, { type: K }>) => void;
};

const handlers = {
  USER_CREATED: (e) => {
    // e.payload.id: string
    console.log('created', e.payload.id);
  },
  USER_DELETED: (e) => {
    console.log('deleted', e.payload.id);
  },
} satisfies HandlerMap;

// handlers는 "모든 이벤트 타입을 빠짐없이" 구현했는지 검증되고
// 각 핸들러의 인자 타입도 정확히 좁혀집니다.

만약 USER_DELETED를 누락하면 컴파일 타임에 바로 잡힙니다. 이런 “누락 방지”는 인프라에서도 중요합니다. 예컨대 Ingress/ALB 규칙 누락은 404/503 같은 증상으로 드러나는데, 그때는 EKS Ingress 503인데 Pod 정상일 때 점검 가이드처럼 체크리스트 기반으로 추적합니다. 애플리케이션 코드에서는 satisfies가 그 체크리스트 역할을 일부 대신해줍니다.

as constsatisfies를 같이 쓰는 기준

둘 다 “리터럴 타입”과 관련이 있지만 역할이 다릅니다.

  • as const: 값을 최대한 좁게(리터럴 + readonly) 고정
  • satisfies: 어떤 타입을 만족하는지 검사하되, 추론 타입은 유지

실전에서는 다음 조합이 자주 나옵니다.

1) 값 자체를 불변 테이블로 만들고 싶을 때: as const

const statusText = {
  200: 'OK',
  404: 'Not Found',
  500: 'Server Error',
} as const;

// statusText[200] 타입은 'OK'

2) “스키마를 만족하는지”도 확인하고 싶을 때: as const + satisfies

type StatusMap = Record<number, string>;

const statusText = {
  200: 'OK',
  404: 'Not Found',
  500: 'Server Error',
} as const satisfies StatusMap;

// - 값은 readonly + 리터럴로 고정
// - 동시에 number -> string 맵 규칙을 만족하는지 검증

주의할 점은 as const satisfies ... 순서입니다. 보통 객체를 먼저 as const로 리터럴/readonly 고정하고, 그 다음 satisfies로 스키마 검증을 거는 식이 직관적입니다.

satisfies로 “정확한 타입”을 얻으려 하면 안 되는 이유

satisfies는 타입을 “부여”하지 않습니다. 검증만 합니다.

type User = { id: string; name: string };

const u = {
  id: '1',
  name: 'Kim',
  extra: 'field',
} satisfies User;

// u는 여전히 extra를 가진 타입으로 추론됩니다.
// 즉, "User 타입 변수"가 필요한 API에는 그대로 넣으면 안 맞을 수 있습니다.

이게 장점이자 단점입니다.

  • 장점: 구체적인 정보(리터럴/추가 필드)를 잃지 않는다.
  • 단점: 어떤 함수가 정확히 User를 요구하면, u는 “더 넓은 구조”라서 할당이 안 될 수 있다(상황에 따라).

이때는 의도를 분리하는 게 좋습니다.

  • 검증용 상수satisfies로 유지
  • 경계(boundary)(예: API 호출 직전)에서 필요한 타입으로 변환/정규화
type User = { id: string; name: string };

const raw = {
  id: '1',
  name: 'Kim',
  extra: 'field',
} satisfies User;

function toUser(x: User): User {
  return x;
}

const user: User = toUser(raw); // 구조적으로는 가능하지만, 팀 규칙에 따라 명시적 정규화 권장

런타임 검증과의 관계: satisfies는 “컴파일 타임” 도구

중요한 오해: satisfies는 런타임에서 아무 것도 하지 않습니다. 즉, 외부 입력(JSON, 환경 변수, HTTP 요청 바디)을 검증하려면 별도의 런타임 검증이 필요합니다.

  • 런타임: zod, valibot, io-ts, custom validator
  • 컴파일 타임: satisfies로 “내가 작성한 상수/테이블”의 무결성을 보장

실전에서는 둘을 같이 씁니다.

import { z } from 'zod';

const ConfigSchema = z.object({
  env: z.enum(['dev', 'stage', 'prod']),
  port: z.coerce.number().int().positive(),
});

type Config = z.infer<typeof ConfigSchema>;

// 1) 외부 입력을 런타임에서 파싱/검증
const parsed = ConfigSchema.parse({
  env: process.env.NODE_ENV,
  port: process.env.PORT,
});

// 2) 내부 상수는 satisfies로 컴파일 타임 검증
const defaults = {
  env: 'dev',
  port: 3000,
} satisfies Config;

이렇게 하면 “외부 입력은 런타임에서”, “내가 작성하는 테이블/기본값은 컴파일 타임에서” 각각 최적의 방식으로 안전성을 확보할 수 있습니다.

실전 팁: satisfies를 쓰기 좋은 곳

  • 라우트 정의 / 권한 맵 / 기능 플래그: 누락과 오타가 치명적
  • 에러 코드/메시지 테이블: 키 누락 방지
  • 테스트 픽스처: 스키마는 맞추되, 리터럴을 유지해 더 정밀한 테스트 작성
  • 인프라/운영 설정 객체: 환경별 변형이 많아질수록 효과가 커짐

운영 환경에서 설정 누락은 종종 “증상은 애매한데 원인은 단순”한 형태로 나타납니다. 예를 들어 로드밸런서 헬스체크 경로 하나 틀려서 타겟이 Unhealthy가 되는 식이죠. 그런 종류의 진단 관점은 EKS에서 NLB 타겟 Unhealthy - 헬스체크·Pod·SG 같은 글이 다루는 영역이고, 애플리케이션 코드에서는 satisfies로 “애초에 틀린 설정이 커밋되지 않게” 만드는 식으로 대응할 수 있습니다.

결론

TypeScript 5.x 시대에 satisfies는 “객체 리터럴을 안전하게 다루는 기본기”에 가깝습니다.

  • : Type 주석은 편하지만 추론을 넓히기 쉽고,
  • as Type 단언은 빠르지만 안전을 해치기 쉽습니다.
  • satisfies Type컴파일 타임 검증리터럴 추론 유지를 동시에 만족시킵니다.

특히 config/맵/핸들러 테이블처럼 객체가 곧 규칙이 되는 코드에서 satisfies를 습관처럼 적용하면, “타입은 맞는데 런타임에서 깨지는” 류의 실수를 상당히 줄일 수 있습니다.