Published on

TS 5.6 satisfies로 타입 유지하며 검증하는 법

Authors

서론

TypeScript에서 “검증(validate)”과 “타입 유지(keep narrow types)”는 자주 충돌합니다. 객체를 어떤 인터페이스로 : Type 주석을 달거나 as Type으로 단언하면, 컴파일러는 그 순간부터 값을 더 넓은 타입으로 취급해 리터럴 타입이 깨지고(예: 'GET'string), 키 목록이 string[]으로 퍼지며, 유니온 분기나 매핑 타입의 정밀도가 떨어집니다.

TS 5.x에서 도입된 satisfies는 이 딜레마를 해결하는 핵심 도구입니다. “이 값은 특정 타입의 제약을 만족해야 한다”는 검증은 수행하되, 변수 자체의 타입은 원래 추론된 구체 타입을 그대로 유지합니다. TS 5.6에서도 이 철학은 동일하며, 특히 설정 객체/라우팅 테이블/권한 맵/이벤트 맵처럼 “형태는 규격을 따라야 하지만 값은 리터럴로 남아야 하는” 영역에서 효과가 큽니다.

이미 satisfies의 기본 개념이 익숙하다면 아래 글도 함께 보면 좋습니다.


satisfies가 해결하는 문제: “검증 때문에 타입이 넓어지는” 현상

가장 흔한 실수는 설정 객체에 타입 주석을 달아버리는 것입니다.

type Route = {
  method: 'GET' | 'POST'
  path: `/${string}`
}

type Routes = Record<string, Route>

// ❌ 타입 주석을 달면 값의 구체 정보가 쉽게 넓어질 수 있다
const routes1: Routes = {
  listUsers: { method: 'GET', path: '/users' },
  createUser: { method: 'POST', path: '/users' },
}

// routes1.listUsers.method 의 타입은 'GET' | 'POST' (리터럴 'GET' 유지 X)

물론 위 자체가 “틀린” 건 아닙니다. 하지만 이후에 routes1를 기반으로 정확한 리터럴 기반 타입(예: 특정 라우트의 method가 항상 'GET')을 뽑아내고 싶다면 손해가 큽니다.

satisfies는 검증만 하고, 변수 타입은 원래 추론된 더 구체적인 타입을 유지합니다.

const routes2 = {
  listUsers: { method: 'GET', path: '/users' },
  createUser: { method: 'POST', path: '/users' },
} satisfies Routes

// routes2.listUsers.method 의 타입은 'GET'
// routes2.createUser.method 의 타입은 'POST'

핵심은:

  • : Routes는 “이 변수의 타입은 Routes”로 고정
  • satisfies Routes는 “이 값은 Routes를 만족해야 함”으로 검증만 수행

TS 5.6에서 특히 유용한 패턴 1: “리터럴 유지 + 스키마 강제” 설정 객체

예를 들어 기능 플래그/권한/요금제 같은 맵을 만든다고 합시다.

type Plan = 'free' | 'pro' | 'enterprise'

type FeatureConfig = {
  enabled: boolean
  minPlan: Plan
}

type FeatureMap = Record<string, FeatureConfig>

const features = {
  exportCsv: { enabled: true, minPlan: 'pro' },
  sso: { enabled: false, minPlan: 'enterprise' },
  // typoPlan: { enabled: true, minPlan: 'enterprize' }, // ✅ 여기서 컴파일 에러
} satisfies FeatureMap

// 타입 유지: features.exportCsv.minPlan 은 'pro'

이 패턴의 장점:

  1. 오타/누락/타입 불일치를 컴파일 타임에 잡음
  2. 값의 리터럴이 유지되어, 이후 조건 분기/타입 계산이 더 정확해짐

패턴 2: as const와의 관계 — 둘 다 쓰는 게 맞나?

결론부터 말하면 “상황에 따라 둘 다”가 맞습니다.

  • as const는 값을 최대한 좁게(리터럴/readonly/튜플) 고정
  • satisfies는 그 좁은 값을 특정 타입 규격에 맞는지 검증

함께 쓰면 “가장 좁게 고정한 값이, 요구 스펙도 만족하는지”를 확인할 수 있습니다.

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'

type ApiSpec = {
  method: HttpMethod
  path: `/${string}`
  auth: 'public' | 'user' | 'admin'
}

const api = {
  list: { method: 'GET', path: '/users', auth: 'user' },
  remove: { method: 'DELETE', path: '/users/:id', auth: 'admin' },
  // broken: { method: 'DEL', path: 'users', auth: 'root' }, // 컴파일 에러
} as const satisfies Record<string, ApiSpec>

// api.list.method: 'GET' (리터럴)
// api.remove.auth: 'admin'

주의할 점:

  • as const만 쓰면 “형태 검증”이 약합니다(예: 키 누락/타입 불일치가 의도치 않게 통과할 수 있는 경우)
  • satisfies만 써도 리터럴은 상당히 유지되지만, readonly/튜플 고정이 필요하면 as const가 추가로 유리합니다.

패턴 3: 키 유니온을 안전하게 뽑기 (Object.keys 지옥 탈출)

런타임의 Object.keys()는 항상 string[]을 반환합니다. 그래서 “정확한 키 유니온”이 필요할 때 타입이 무너집니다.

satisfies로 맵을 만들면, 키 유니온을 keyof typeof로 안정적으로 얻을 수 있습니다.

type Job = {
  cron: string
  handler: () => Promise<void>
}

const jobs = {
  cleanup: {
    cron: '0 3 * * *',
    handler: async () => { /* ... */ },
  },
  sync: {
    cron: '*/5 * * * *',
    handler: async () => { /* ... */ },
  },
} satisfies Record<string, Job>

type JobName = keyof typeof jobs
// 'cleanup' | 'sync'

function runJob(name: JobName) {
  return jobs[name].handler()
}

여기서 jobs: Record<string, Job>로 선언해버리면 keyof typeof jobsstring으로 넓어져 버립니다. satisfies는 이 문제를 근본적으로 피하게 해줍니다.


패턴 4: “정확한 반환 타입”을 유지하면서도 규격 준수

함수 반환값을 인터페이스로 맞추고 싶을 때도 흔히 리터럴이 깨집니다.

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

function makeUserCreated(id: string) {
  // 반환 타입 주석을 달면 넓어질 수 있음
  // return { type: 'USER_CREATED', payload: { id } } as Event

  // ✅ 검증 + 리터럴 유지
  return {
    type: 'USER_CREATED',
    payload: { id },
  } satisfies Event
}

const e = makeUserCreated('u1')
// e.type 은 'USER_CREATED'로 유지

이건 특히 이벤트 소싱/메시지 큐/도메인 이벤트에서 큰 차이를 만듭니다. type 필드가 넓어지면 이후 switch 분기에서 내로잉이 약해지고, payload 타입도 덜 정밀해집니다.


패턴 5: “과잉 속성(excess property)”을 잡되, 타입은 그대로

객체 리터럴을 다른 곳에 넘길 때 TS는 과잉 속성 검사를 합니다. 하지만 중간 변수에 담으면 검사가 약해지는 경우가 있습니다.

satisfies는 “이 객체는 이 타입을 만족해야 한다”를 명시하므로, 설정/테이블을 작성할 때 불필요한 필드가 끼는 문제를 초기에 잡는 데 도움이 됩니다.

type LoggerConfig = {
  level: 'debug' | 'info' | 'warn' | 'error'
  json: boolean
}

const loggerConfig = {
  level: 'info',
  json: true,
  // colorize: true, // ✅ 여기서 에러(정의되지 않은 속성)
} satisfies LoggerConfig

satisfies 사용 시 주의사항과 실전 팁

1) satisfies는 “타입 변환”이 아니다

satisfies는 타입을 바꾸지 않습니다. 즉, 아래는 불가능합니다.

  • satisfies로 런타임 검증을 대체할 수 없음
  • unknown 값을 안전하게 바꾸는 용도가 아님

런타임 입력(JSON, API 응답)은 Zod/Valibot/io-ts 같은 검증이 필요합니다. satisfies는 “코드에 작성한 상수/구성”의 정적 검증에 최적입니다.

2) Record<string, T>보다 “정확한 키”를 살리는 타입을 고려

키가 고정 집합이라면 Record<'a'|'b', T>처럼 더 구체적으로 만들고, 거기에 satisfies를 얹으면 오타를 더 강하게 막습니다.

type Env = 'dev' | 'prod'

type DbConfig = { url: string }

const db = {
  dev: { url: 'postgres://localhost:5432/app' },
  prod: { url: 'postgres://prod:5432/app' },
  // prdo: { url: '...' }, // ✅ 오타 즉시 검출
} satisfies Record<Env, DbConfig>

3) “검증은 강하게, 사용은 가볍게”가 목표

  • 검증은 satisfies
  • 사용처에서는 typeof와 인덱싱으로 리터럴 기반 타입을 최대한 활용

이 조합이 TS를 “문서화 도구”가 아니라 “정밀한 정적 분석기”로 쓰게 해줍니다.


결론: TS 5.6에서 satisfies는 ‘검증’과 ‘정밀함’을 동시에 준다

TS에서 타입 시스템을 잘 쓰는 팀일수록 설정/테이블/맵/스펙 정의 코드가 많아지고, 그만큼 “검증 때문에 타입이 퍼지는 문제”를 자주 만납니다. TS 5.6의 satisfies는 이 지점에서 가장 비용 대비 효과가 큰 도구입니다.

정리하면:

  • : Type는 타입을 고정해 추론 정보를 잃을 수 있음
  • as Type는 위험한 단언이 되어 오류를 숨길 수 있음
  • satisfies Type오류는 숨기지 않고, 추론은 유지한다

다음에 라우팅 테이블, 권한 맵, 이벤트 맵, 환경별 설정 같은 “정적 데이터”를 작성할 때는 satisfies를 기본 옵션으로 두고 시작하면, 런타임 버그로 이어질 설정 실수를 컴파일 타임에 상당 부분 차단할 수 있습니다.