- Published on
TypeScript 5.x satisfies로 타입 검증·추론 최적화
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 오래 쓰다 보면 “타입을 엄격히 걸고 싶지만, 추론이 죽어버리는 건 싫다”는 딜레마를 자주 만납니다. 예를 들어 설정 객체(config), 라우팅 테이블, 이벤트 핸들러 맵, 권한 정책(policy) 같은 정적 데이터 구조는 컴파일 타임에 강하게 검증되길 원하지만, 동시에 각 필드의 리터럴 타입(예: 'GET' | 'POST', 'admin', 'ko-KR')은 그대로 살아 있어야 후속 로직에서 자동완성/분기 추론이 잘 됩니다.
TypeScript 5.x(도입은 4.9지만 5.x에서 사실상 표준 패턴이 됨)의 satisfies는 이 문제를 깔끔하게 해결합니다. 핵심은 다음 한 줄입니다.
: SomeType(타입 주석)처럼 값의 타입을 SomeType으로 “고정”하지 않고as SomeType(단언)처럼 검증을 건너뛰지도 않으며- “이 값이 SomeType을 만족하는지”만 검사하고, 값 자체의 구체적(리터럴) 타입 추론은 유지합니다.
이 글에서는 satisfies가 실제로 어떤 차이를 만드는지, 언제 쓰면 좋은지, 그리고 팀 코드베이스에서 어떤 형태로 정착시키면 좋은지 실전 예제로 정리합니다.
satisfies 한 문장 정의: "검증만 하고, 추론은 그대로"
먼저 비교를 위해 가장 흔한 세 가지 방식을 나란히 보겠습니다.
1) 타입 주석 : T — 검증은 되지만 추론이 약해짐
type Route = {
method: 'GET' | 'POST'
path: `/${string}`
}
const routes: Record<string, Route> = {
home: { method: 'GET', path: '/' },
login: { method: 'POST', path: '/login' },
}
// routes.home.method의 타입은 'GET' | 'POST' (리터럴 'GET'이 아니라)
routes.home.method는 원래 'GET'이라는 리터럴 정보가 있었지만, Record<string, Route>로 고정되면서 'GET' | 'POST'로 넓어집니다. 이후 분기/매핑에서 더 많은 타입 가드가 필요해질 수 있습니다.
2) 타입 단언 as T — 추론은 바뀌지만 검증이 사라짐
const routes = {
home: { method: 'GET', path: '/' },
login: { method: 'POST', path: '/login' },
} as Record<string, { method: 'GET' | 'POST'; path: `/${string}` }>
// 오타가 있어도(예: methdo) 컴파일러가 못 잡을 수 있음
as는 “내가 맞다고 할 테니 믿어”에 가깝습니다. 설계 의도상 위험한 지점입니다.
3) satisfies — 검증은 강하게, 추론은 구체적으로
type Route = {
method: 'GET' | 'POST'
path: `/${string}`
}
const routes = {
home: { method: 'GET', path: '/' },
login: { method: 'POST', path: '/login' },
} satisfies Record<string, Route>
// routes.home.method의 타입은 'GET' (리터럴 유지)
// 동시에 Route 조건을 만족하는지도 컴파일 타임에 검증됨
이게 satisfies의 본질입니다. 오브젝트의 “형태”는 계약(Contract)으로 검증하고, 값의 구체 타입은 그대로 보존합니다.
왜 5.x에서 더 중요해졌나: 정적 데이터 모델링이 늘었다
TypeScript 5.x 시대의 프런트/백엔드/풀스택 코드에서는 다음 패턴이 흔합니다.
- 라우팅/핸들러 테이블:
Record<string, Handler> - 권한 정책:
Record<Role, Permission[]> - i18n 리소스:
Record<Locale, Dictionary> - 이벤트 버스:
Record<EventName, PayloadSchema> - API 스펙/클라이언트:
const endpoints = { ... }
이런 구조는 “런타임에 만드는 객체”라기보다 “컴파일 타임에 설계하는 데이터”에 가깝습니다. 따라서
- 실수는 컴파일에서 잡고
- 이후 코드는 최대한 자동 추론으로 편하게
가 이상적입니다. satisfies는 이 요구를 정면으로 해결합니다.
실전 1: 라우트 테이블에서 리터럴 유지 + 계약 검증
아래는 라우트 정의를 한 곳에 모아두고, 이후에 타입 안전하게 사용하고 싶은 케이스입니다.
type RouteDef = {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
path: `/${string}`
auth: 'public' | 'user' | 'admin'
}
const routeTable = {
health: { method: 'GET', path: '/health', auth: 'public' },
me: { method: 'GET', path: '/me', auth: 'user' },
adminUsers: { method: 'GET', path: '/admin/users', auth: 'admin' },
// 아래처럼 실수하면 즉시 컴파일 에러
// broken: { method: 'FETCH', path: 'admin', auth: 'root' },
} satisfies Record<string, RouteDef>
// 키는 그대로 리터럴 유니온으로 추론됨
export type RouteKey = keyof typeof routeTable
function buildUrl(key: RouteKey) {
return routeTable[key].path
}
// routeTable.adminUsers.auth는 'admin' 리터럴
Record<string, RouteDef>를 타입 주석으로 붙였다면 auth는 'public' | 'user' | 'admin'으로 넓어져서, auth === 'admin' 같은 분기 최적화가 약해질 수 있습니다. satisfies는 이 문제를 피합니다.
실전 2: as const와의 조합 — “고정”은 최소로, 검증은 최대
as const는 객체 전체를 깊게 readonly + 리터럴로 고정합니다. 하지만 너무 일찍/너무 크게 쓰면 수정이 불편해지거나, 함수 인자 호환성이 떨어질 수 있습니다. 보통은 다음처럼 조합합니다.
- 리터럴을 살리고 싶은 정적 테이블에만
as const - 그 테이블이 특정 인터페이스를 만족하는지
satisfies
type FeatureFlag = {
description: string
ownerTeam: 'core' | 'growth' | 'platform'
defaultEnabled: boolean
}
const flags = {
newCheckout: {
description: '신규 결제 플로우',
ownerTeam: 'growth',
defaultEnabled: false,
},
fastSearch: {
description: '검색 성능 개선',
ownerTeam: 'platform',
defaultEnabled: true,
},
} as const satisfies Record<string, FeatureFlag>
// flags.fastSearch.ownerTeam === 'platform' 리터럴
// 동시에 FeatureFlag 형태 검증
여기서 포인트는 as const가 “값을 최대한 구체화”하고, satisfies가 “그 값이 계약을 준수하는지 확인”한다는 점입니다.
실전 3: satisfies로 “키 누락/오타”를 컴파일 타임에 봉쇄
역할(Role) 기반 권한 매핑처럼 키가 제한된 집합일 때 특히 강력합니다.
type Role = 'guest' | 'user' | 'admin'
type Permission =
| 'read:article'
| 'write:comment'
| 'delete:comment'
| 'manage:users'
const rolePermissions = {
guest: ['read:article'],
user: ['read:article', 'write:comment'],
admin: ['read:article', 'write:comment', 'delete:comment', 'manage:users'],
// superAdmin: ['manage:users'], // <- 키가 Role에 없으면 에러
} satisfies Record<Role, readonly Permission[]>
// 'admin' 키 누락 시에도 에러: Record<Role, ...>를 만족 못 함
이 패턴은 “운영 중 특정 역할만 권한 테이블에서 빠져서 장애” 같은 류의 실수를 사전에 차단합니다. (권한/경계의 중요성은 도메인 경계 관점에서도 자주 문제를 만드는데, 관련해서는 DDD 애그리게이트 경계 깨짐 - 해결 7가지도 함께 참고할 만합니다.)
실전 4: 함수 인자 타입은 넓게, 내부 테이블은 좁게
satisfies가 특히 빛나는 지점은 “내부 상수는 리터럴로 좁게 유지하되, 외부 API는 일반적인 타입으로 받는” 구조입니다.
type Env = 'dev' | 'stage' | 'prod'
type EndpointConfig = {
baseUrl: `https://${string}`
timeoutMs: number
}
const endpoints = {
dev: { baseUrl: 'https://dev.api.example.com', timeoutMs: 3_000 },
stage: { baseUrl: 'https://stage.api.example.com', timeoutMs: 5_000 },
prod: { baseUrl: 'https://api.example.com', timeoutMs: 8_000 },
} satisfies Record<Env, EndpointConfig>
function createClient(env: Env) {
// endpoints[env].baseUrl은 env에 따라 리터럴 유니온으로 남아있음
return endpoints[env]
}
타입 주석으로 const endpoints: Record<Env, EndpointConfig> = ...를 붙이면 baseUrl 리터럴들이 https://${string}로 넓어져 “환경별로 다른 값을 가진다”는 정보가 흐려집니다.
satisfies가 해결하는 대표적인 문제들
1) 과도한 widening(타입 확장) 방지
: SomeType은 값의 타입을 SomeType으로 “결정”해버립니다.satisfies SomeType은 값의 타입을 “결정”하지 않고, 조건만 검사합니다.
2) 객체 리터럴의 excess property check(초과 속성 검사) 유지
객체 리터럴은 원래 “정의되지 않은 키를 넣으면” 잡아주는 초과 속성 검사가 강합니다. 그런데 as를 쓰면 이 안전장치가 사라지기 쉽습니다. satisfies는 이 검사를 유지한 채로 계약을 강제합니다.
3) 키 유니온(keyof typeof)을 자연스럽게 얻기
정적 테이블을 만들고 keyof typeof table로 키 유니온을 뽑는 패턴은 흔합니다. satisfies는 테이블의 값 타입을 검증하면서도, 키/값의 리터럴 정보를 보존해서 이 패턴과 궁합이 좋습니다.
주의할 점과 안티패턴
1) satisfies는 “타입 변환”이 아니다
satisfies는 결과 타입을 바꾸지 않습니다. 즉, 아래는 “타입이 User로 바뀌는” 게 아닙니다.
type User = { id: string; name: string }
const u = { id: '1', name: 'kim', extra: 123 } satisfies User
// u.extra는 여전히 존재하는 속성(타입도 유지)입니다.
따라서 “extra를 금지하고 싶다”면 설계를 바꿔야 합니다. 예를 들어 정확 타입(exact type)을 강제하는 유틸을 쓰거나, 입력 경계를 런타임 스키마(zod 등)로 검증하는 방식이 필요합니다.
2) satisfies만으로 런타임 안전성이 생기지 않는다
TypeScript 타입은 컴파일 타임 도구입니다. 외부 입력(JSON, API 응답)을 믿고 satisfies를 붙인다고 런타임에 검증되는 것이 아닙니다. 외부 입력은 반드시 런타임 검증이 필요합니다.
3) 너무 큰 객체에 무분별하게 적용하면 컴파일 비용이 증가할 수 있음
대규모 테이블(수천 키)에서 복잡한 조건 타입을 만족시키게 하면 타입 체커 비용이 늘 수 있습니다. 이 경우에는
- 테이블을 파일/모듈로 쪼개거나
- 타입을 단순화하거나
- 생성 코드를 일부 런타임으로 옮기고 스키마 검증을 도입
같은 전략을 고려하세요.
팀 적용 가이드: 어떤 곳에 satisfies를 표준으로 둘까
다음 체크리스트에 해당하면 satisfies를 우선 고려하는 게 좋습니다.
- 상수 테이블/설정 객체를 만들고
keyof typeof를 뽑아 쓴다. - 값의 리터럴(문자열/숫자) 정보가 후속 로직에서 중요하다.
as SomeType단언이 코드 곳곳에 퍼져 있다(= 안전장치가 약해진 상태).- “키 누락/오타”가 장애로 이어진 적이 있다.
반대로 다음이면 굳이 satisfies가 필요 없을 수 있습니다.
- 단순 DTO 타입 주석만으로 충분하고, 리터럴 추론이 중요하지 않다.
- 객체가 정적 데이터가 아니라 런타임에 동적으로 생성된다.
마무리
TypeScript에서 타입을 잘 쓴다는 건 “엄격함”과 “개발 경험(추론/자동완성)” 사이의 균형을 잡는 일입니다. satisfies는 그 균형점을 한 단계 끌어올린 도구입니다.
: T처럼 타입을 고정하지 않고as T처럼 검증을 포기하지 않으며- 검증과 추론을 동시에 최적화합니다.
특히 라우팅/권한/설정/정적 스펙 테이블을 많이 다루는 코드베이스라면, satisfies를 기본 패턴으로 채택하는 것만으로도 타입 안정성과 생산성이 함께 올라갑니다.
추가로 TypeScript 5.6 환경에서 데코레이터를 쓰다 런타임 오류를 겪는 경우가 있다면, 설정/트랜스파일 단계에서의 함정도 함께 점검해보는 게 좋습니다: TS 5.6 데코레이터 적용 시 런타임 오류 해결