- Published on
TS 5.x satisfies로 타입 안전과 추론을 함께
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 충돌하는 요구가 하나 있습니다.
- 타입을 엄격하게 맞추고 싶다
- 그런데도 리터럴 추론은 최대한 유지하고 싶다
전통적으로는 둘 중 하나를 포기했습니다. 타입을 맞추려면 : SomeType로 주석을 달아 추론이 넓어지고, 추론을 유지하려면 as const나 as SomeType 같은 단언으로 도망가며 타입 안전을 희생하는 경우가 많았습니다.
TypeScript 5.x의 satisfies는 이 딜레마를 꽤 깔끔하게 해결합니다. 핵심은 간단합니다.
satisfies는 값이 특정 타입 조건을 “만족하는지”만 검사합니다- 하지만 값 자체의 타입은 “그 값이 가진 가장 구체적인 타입”으로 유지됩니다
즉, 검증은 엄격하게 하고 추론은 좁게 가져갈 수 있습니다.
아래에서는 satisfies가 왜 유용한지, : 타입 주석이나 as와 무엇이 다른지, 그리고 팀 코드베이스에서 바로 써먹을 패턴을 코드로 정리합니다.
: 타입 주석 vs as 단언 vs satisfies
1) : 타입 주석은 추론을 넓히기 쉽다
다음은 흔히 보는 설정 객체 패턴입니다.
type RouteConfig = {
path: string
method: "GET" | "POST"
}
const routes: Record<string, RouteConfig> = {
home: { path: "/", method: "GET" },
login: { path: "/login", method: "POST" },
}
문제는 routes.home.method의 타입이 이미 "GET"이 아니라 "GET" | "POST"로 넓어져 있다는 점입니다. Record<string, RouteConfig>로 주석을 달면서 내부 프로퍼티가 전부 RouteConfig로 “맞춰져 버리기” 때문입니다.
이게 항상 나쁜 건 아니지만, 아래처럼 리터럴 기반으로 분기하거나 매핑할 때 DX가 급격히 나빠집니다.
function handle(method: "GET" | "POST") {
// ...
}
handle(routes.home.method) // OK지만, 더 좁은 정보는 잃었다
2) as 단언은 안전장치를 제거한다
추론을 유지하려고 단언을 쓰면, 컴파일러가 경고해줄 기회를 잃습니다.
type RouteConfig = {
path: string
method: "GET" | "POST"
}
const routes = {
home: { path: "/", method: "GET" },
// method 오타가 있어도 단언이 막아버릴 수 있다
login: { path: "/login", method: "POSt" },
} as Record<string, RouteConfig>
위 코드는 실제로는 에러가 나야 정상인데, 단언이 “맞다고 쳐”를 강요해버립니다. 팀 규모가 커질수록 이런 패턴은 런타임 버그로 직결됩니다.
3) satisfies는 검증은 하되, 값 타입은 유지한다
routes 자체는 리터럴 추론을 유지하면서도, Record<string, RouteConfig> 조건을 만족하는지 검사할 수 있습니다.
type RouteConfig = {
path: string
method: "GET" | "POST"
}
const routes = {
home: { path: "/", method: "GET" },
login: { path: "/login", method: "POST" },
} satisfies Record<string, RouteConfig>
// 여기서 routes.home.method는 "GET"으로 유지될 가능성이 커진다
그리고 오타가 있으면 제대로 잡힙니다.
const routes2 = {
login: { path: "/login", method: "POSt" },
} satisfies Record<string, RouteConfig>
// 타입 에러: "POSt"는 "GET" | "POST"에 할당 불가
정리하면 아래와 같습니다.
:주석: 타입을 “그 타입으로 만들기” 때문에 추론이 넓어질 수 있음as단언: 타입 검증을 우회할 수 있어 위험satisfies: 타입 검증은 하되, 값의 구체 타입은 유지
패턴 1: 설정 객체에서 리터럴 정보 살리기
프론트엔드나 백엔드 모두에서 “설정 객체”는 필연적으로 생깁니다. 예를 들어 API 엔드포인트 정의, 권한 매트릭스, 기능 플래그, 이벤트 이름 매핑 같은 것들입니다.
다음 예시는 API 정의를 한 곳에서 관리하면서, 각 항목의 method 리터럴을 그대로 살리는 패턴입니다.
type Endpoint = {
method: "GET" | "POST" | "PUT" | "DELETE"
path: string
}
const api = {
getUser: { method: "GET", path: "/users/:id" },
updateUser: { method: "PUT", path: "/users/:id" },
} satisfies Record<string, Endpoint>
type ApiKey = keyof typeof api
function callApi(key: ApiKey) {
const { method, path } = api[key]
// method는 key에 따라 더 구체적으로 추론될 여지가 생긴다
return { method, path }
}
여기서 얻는 이점은 두 가지입니다.
- 항목 누락이나 오타를 타입 레벨에서 강제
- 각 항목의 리터럴 타입을 유지해 downstream 코드에서 더 정확한 추론 가능
패턴 2: 유니온 키를 강제하는 “빠진 키 감지”
Record는 키 집합을 강제할 때 특히 유용합니다. 예를 들어 권한 매핑을 "ADMIN" | "USER" | "GUEST"에 대해 반드시 정의해야 한다면 다음처럼 작성할 수 있습니다.
type Role = "ADMIN" | "USER" | "GUEST"
type RolePolicy = {
canDelete: boolean
canWrite: boolean
}
const policy = {
ADMIN: { canDelete: true, canWrite: true },
USER: { canDelete: false, canWrite: true },
GUEST: { canDelete: false, canWrite: false },
} satisfies Record<Role, RolePolicy>
만약 GUEST를 빼먹으면 컴파일 에러가 납니다. 반대로 policy의 각 값은 리터럴 기반으로 더 구체적으로 남아 있을 수 있어, 이후 로직에서 역할별 분기를 더 똑똑하게 만들 수 있습니다.
이 패턴은 기능 플래그에도 그대로 적용됩니다.
type Feature = "NEW_CHECKOUT" | "BETA_SEARCH"
const featureFlags = {
NEW_CHECKOUT: true,
BETA_SEARCH: false,
} satisfies Record<Feature, boolean>
패턴 3: 이벤트 이름과 페이로드 매핑
이벤트 기반 설계에서 자주 하는 실수는 “이벤트 이름은 맞는데 페이로드 shape가 다름”입니다. satisfies는 이벤트 맵 정의 시 검증과 추론을 동시에 제공합니다.
type EventMap = {
"user.created": { id: string; email: string }
"user.deleted": { id: string }
}
const events = {
"user.created": { id: "", email: "" },
"user.deleted": { id: "" },
} satisfies EventMap
function emit<E extends keyof EventMap>(
name: E,
payload: EventMap[E]
) {
// ...
}
emit("user.created", { id: "1", email: "a@b.com" })
// emit("user.created", { id: "1" }) // 타입 에러
여기서 events 객체는 “정의가 EventMap을 만족하는지” 검증받고, 동시에 각 값은 리터럴/구체 타입 정보가 최대한 유지됩니다.
패턴 4: as const와의 조합 지점
as const는 값을 깊게 readonly로 만들고 리터럴로 고정합니다. 하지만 as const만으로는 “정해진 타입을 만족하는지”를 강제하지 못합니다.
둘을 섞어 쓰는 대표 패턴은 “리터럴 배열을 유지하면서, 각 원소가 특정 타입을 만족하는지 검증”하는 경우입니다.
type Column = {
key: string
header: string
}
const columns = [
{ key: "id", header: "ID" },
{ key: "email", header: "Email" },
] as const satisfies readonly Column[]
type ColumnKey = (typeof columns)[number]["key"]
// "id" | "email"
이 조합은 테이블 컬럼, 폼 필드, 라우트 목록 같은 곳에서 특히 강력합니다.
as const로ColumnKey같은 리터럴 유니온을 뽑아내고satisfies로 각 원소 shape 검증을 놓치지 않습니다
패턴 5: “추론을 유지한 채” 핸들러 맵 만들기
핸들러 맵은 객체 리터럴로 만들면 키와 함수 시그니처를 동시에 관리할 수 있어 좋지만, 타입을 잘못 붙이면 추론이 죽습니다.
다음은 satisfies를 이용해 핸들러들의 시그니처를 강제하면서도, 구현부에서는 각 함수의 구체 타입을 유지하는 방식입니다.
type Handlers = {
start: () => void
stop: (reason: "user" | "system") => void
}
const handlers = {
start() {
// ...
},
stop(reason) {
// reason는 "user" | "system"으로 추론되어야 한다
if (reason === "user") {
// ...
}
},
} satisfies Handlers
여기서 stop(reason)의 reason은 컨텍스트 타입으로부터 추론됩니다. 반면 객체 전체를 const handlers: Handlers = ...로 두면, 구현부에서 타입이 맞춰지긴 하지만 다른 리터럴 정보가 넓어지거나, 경우에 따라서는 추가적인 단언이 필요해지는 상황이 생깁니다.
실전에서 자주 만나는 함정과 체크리스트
1) satisfies는 타입을 “바꾸지” 않는다
satisfies는 검증만 합니다. 따라서 다음처럼 “더 넓은 타입으로 만들고 싶다”는 목적에는 맞지 않습니다.
type Config = { mode: "dev" | "prod" }
const config = { mode: "dev" } satisfies Config
// config.mode는 여전히 "dev"로 남을 수 있다
이게 장점인 동시에, 어떤 API가 "dev" | "prod" 전체를 요구하는 시그니처라면 호출 지점에서 조정이 필요할 수 있습니다.
2) 초과 프로퍼티 검사는 상황에 따라 기대와 다를 수 있다
객체 리터럴을 직접 할당할 때의 “초과 프로퍼티 검사”와, 중간 변수로 빼는 경우의 동작 차이는 여전히 존재합니다. satisfies는 “만족 여부”를 검사하지만, 팀 컨벤션으로 “정의 객체에는 불필요한 키를 넣지 않는다” 같은 룰까지 강제하려면 린트 규칙이나 별도 유틸 타입이 필요할 수 있습니다.
3) Record<string, ...>는 너무 느슨할 수 있다
키를 엄격히 강제하고 싶다면 Record<string, T> 대신 유니온 키를 쓰는 편이 좋습니다.
- 느슨함:
Record<string, T> - 엄격함:
Record<"A" | "B", T>
이 차이가 satisfies의 효과를 크게 좌우합니다.
마이그레이션 가이드: as를 satisfies로 바꾸는 순서
기존 코드베이스에서 as SomeType가 난무한다면, 다음 순서로 바꾸는 것을 권합니다.
- “설정 객체” “매핑 객체” “핸들러 맵”처럼 정적 구조가 뚜렷한 곳부터 찾기
as SomeType를 제거하고satisfies SomeType로 교체- 새로 드러나는 타입 에러를 진짜 문제로 취급하고 수정
- 리터럴 유니온이 필요하면
as const satisfies ...조합으로 정리
이 과정에서 얻는 가장 큰 수익은, 타입 단언으로 가려졌던 오류가 컴파일 타임에 다시 드러난다는 점입니다.
언제 satisfies가 특히 빛나는가
- 라우트, 권한, 기능 플래그, 이벤트 맵처럼 “키-값 매핑”이 핵심인 코드
- 리터럴 유니온을 뽑아내 downstream 타입을 자동 생성하고 싶은 코드
as단언을 줄여 타입 안정성을 회복하고 싶은 코드- 라이브러리 수준의 유틸을 만들 때, 사용자 입력 객체를 검증하면서도 추론을 살리고 싶은 코드
이 주제는 타입 좁히기 관점에서도 더 깊게 확장됩니다. 실전에서 satisfies로 타입을 더 공격적으로 좁히는 패턴은 아래 글에서 이어서 정리해두었습니다.
결론: satisfies는 “검증”과 “추론”의 타협이 아니다
TypeScript에서 타입은 종종 “안전”과 “편의” 사이의 줄다리기가 됩니다. satisfies는 이 줄다리기에서 드물게도 둘 다를 얻는 도구에 가깝습니다.
- 객체가 특정 인터페이스나
Record제약을 만족하는지 확실히 검사하고 - 값의 리터럴/구체 타입은 유지해서
- downstream 코드의 자동완성, 분기, 매핑 타입 생성까지 더 강력하게 만듭니다
정적 정의가 많은 코드일수록 효과가 크니, 우선 설정 객체 한 군데부터 as를 걷어내고 satisfies로 교체해보면 체감이 빠를 것입니다.