- Published on
TS 5.x satisfies로 타입검증·추론 동시 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 TypeScript를 쓰다 보면 설정 객체, 라우팅 테이블, 권한 매핑, 이벤트 스키마 같은 “큰 객체 리터럴”을 자주 다룹니다. 이때 늘 부딪히는 문제가 하나 있습니다.
- 타입 검증을 강하게 걸고 싶다
- 동시에 객체 값의 구체적인 리터럴 타입 추론도 잃고 싶지 않다
기존에는 as SomeType 단언으로 “일단 통과”시키거나, : SomeType 주석으로 타입을 고정해버려 추론을 희생하는 방식이 흔했습니다. TypeScript 5.x의 satisfies는 이 둘을 동시에 해결합니다.
이 글에서는 satisfies가 정확히 무엇을 보장하는지, as와 어떤 차이가 있는지, 그리고 실무에서 가장 자주 쓰는 패턴을 코드로 정리합니다.
satisfies 한 문장 정의
expr satisfies T는 다음을 동시에 수행합니다.
expr가 타입T를 만족하는지 컴파일 타임에 검증한다- 하지만
expr의 “표현식 타입”은 가능한 한 구체적으로 유지한다
즉, 타입 검증은 T 기준으로 하되, 추론 결과는 T로 강제 캐스팅하지 않습니다.
: T 주석과 as T 단언이 가진 한계
: T는 추론을 T로 고정한다
type Role = "admin" | "user";
type Config = {
role: Role;
retry: number;
};
// 타입 주석을 달면, 각 필드 타입이 Config 기준으로 "넓어질" 수 있음
const config: Config = {
role: "admin",
retry: 3,
};
// config.role의 타입은 "admin"이 아니라 Role
이게 항상 나쁜 건 아니지만, 아래처럼 리터럴 기반으로 분기하거나 키를 뽑아 쓰는 코드에서는 손해가 큽니다.
as T는 검증을 약하게 만들 수 있다
type Config = {
role: "admin" | "user";
retry: number;
};
// 단언은 "검증"이 아니라 "우기기"에 가깝다
const config = {
role: "super-admin", // 실수
retry: "3", // 실수
} as Config;
// 컴파일이 통과할 수 있고, 런타임에서 터진다
물론 TS는 많은 경우 단언에서도 경고를 주지만, 단언은 구조적 검증을 우회하는 통로가 되기 쉽습니다. 특히 제네릭/유틸 타입이 섞이면 실수 가능성이 커집니다.
satisfies 기본 예제: 검증하면서 리터럴 유지
type Config = {
role: "admin" | "user";
retry: number;
};
const config = {
role: "admin",
retry: 3,
} satisfies Config;
// 여기서 config.role 타입은 "admin" (리터럴 유지)
// 동시에 Config 조건을 만족하는지 검증됨
이제 다음이 동시에 가능합니다.
role이"admin" | "user"밖이면 컴파일 에러retry가 숫자가 아니면 컴파일 에러- 하지만
config.role은"admin"로 남아서 더 정밀한 분기/추론이 가능
실무 패턴 1: 라우팅/핸들러 테이블에서 키와 시그니처 검증
API 라우트나 이벤트 핸들러를 객체로 관리할 때, 흔히 “키는 정해진 집합”이고 “값은 함수 시그니처가 맞아야” 합니다.
type Route = "/health" | "/users" | "/payments";
type Handler = (req: { traceId: string }) => Promise<{ status: number }>;
type RouteTable = Record<Route, Handler>;
const routes = {
"/health": async (req) => ({ status: 200 }),
"/users": async (req) => ({ status: 200 }),
"/payments": async (req) => ({ status: 202 }),
// "/admin": async () => ({ status: 200 }), // 여기는 컴파일 에러: Route에 없음
} satisfies RouteTable;
// routes의 키/값은 구체적으로 유지되면서, RouteTable을 만족하는지 검증됨
여기서 routes를 : RouteTable로 선언하면 타입이 Record<Route, Handler>로 고정되어 개별 핸들러의 더 구체적인 반환 타입 정보를 잃을 수 있습니다. satisfies는 “테이블 모양 검증”만 하고, 표현식 자체의 구체성은 남겨둡니다.
실무 패턴 2: 설정 객체에서 as const와 조합하기
as const는 리터럴을 극단적으로 좁히고 readonly로 만듭니다. 다만 이것만 쓰면 “원하는 스키마를 만족하는지” 검증이 약해질 수 있습니다. satisfies를 붙이면 둘을 같이 가져갈 수 있습니다.
type AppConfig = {
env: "dev" | "prod";
logLevel: "debug" | "info" | "warn" | "error";
featureFlags: Record<string, boolean>;
};
const appConfig = {
env: "prod",
logLevel: "info",
featureFlags: {
enableNewCheckout: true,
enableVerboseAudit: false,
},
} as const satisfies AppConfig;
// env는 "prod"로 유지
// logLevel은 "info"로 유지
// 동시에 AppConfig 스키마 검증
운영 환경에서 설정 실수는 장애로 직결됩니다. MSA 환경에서 중복 처리나 멱등성이 중요한 것처럼, 설정 또한 “컴파일 타임에 가능한 한 많이” 잡는 게 비용이 가장 낮습니다. 멱등 설계가 궁금하면 Kubernetes MSA에서 멱등키로 중복결제 막기도 함께 참고할 만합니다.
실무 패턴 3: 유니온 기반 이벤트 맵 만들기
이벤트 이름과 페이로드를 매핑할 때도 satisfies가 강력합니다.
type EventMap = {
"user.created": { userId: string; email: string };
"payment.completed": { paymentId: string; amount: number };
};
type EventName = keyof EventMap;
type Publisher = {
[K in EventName]: (payload: EventMap[K]) => void;
};
const publish = {
"user.created": (payload) => {
payload.userId;
payload.email;
},
"payment.completed": (payload) => {
payload.paymentId;
payload.amount;
},
// "payment.completed": (payload) => payload.amount.toUpperCase(),
// 컴파일 에러: amount는 number
} satisfies Publisher;
이 패턴은 이벤트 드리븐 아키텍처에서 특히 유용합니다. 이벤트 발행/소비가 엇갈릴 때 중복/유실을 막는 전략이 필요하다면 MSA 트랜잭션 아웃박스 패턴으로 중복·유실 막기도 함께 읽으면 설계 관점이 이어집니다.
satisfies가 “추론을 유지”한다는 의미를 더 정확히
const x = expr satisfies T에서 x의 타입은 기본적으로 expr의 타입입니다. 단, 컴파일러는 expr가 T에 할당 가능해야 한다는 제약을 검증합니다.
즉 다음이 성립합니다.
satisfies는 타입을 바꾸지 않는다- 타입 에러를 더 잘 드러내는 “검증 장치”다
이 차이 때문에 satisfies는 “타입 설계를 더 안전하게 만들면서도 DX를 해치지 않는” 도구로 평가받습니다.
자주 하는 실수: satisfies를 런타임 검증으로 착각
Object satisfies T는 런타임에서 아무 것도 하지 않습니다. JSON을 읽어온 값, 외부 API 응답 같은 “런타임 데이터”는 별도의 검증이 필요합니다.
예를 들어 다음 코드는 안전하지 않습니다.
type User = { id: string; age: number };
const user = JSON.parse('{"id":"u1","age":"not-a-number"}') satisfies User;
// 컴파일 타임엔 통과할 수 있지만, 런타임 user.age는 string일 수 있음
JSON.parse의 반환 타입은 any에 가깝기 때문에, 컴파일러가 제대로 검증할 근거가 없습니다. 이 경우에는 스키마 검증 라이브러리나 타입 가드를 함께 써야 합니다.
참고로 TS 버전 업에서 타입 가드나 타입 프레디킷 동작이 바뀌어 당황하는 경우가 있는데, 관련 이슈를 다룬 글로 TS 5.5에서 type predicate 깨짐 해결법도 연결해두겠습니다.
언제 satisfies를 쓰면 좋은가
다음 조건이면 거의 satisfies가 정답입니다.
- 객체 리터럴을 “정해진 스키마”로 검증하고 싶다
- 그런데 값의 리터럴 타입, 키의 구체성, 함수의 구체적인 반환 타입 같은 추론을 유지하고 싶다
as단언으로 타입 안정성을 희생하고 싶지 않다
특히 설정, 테이블, 맵, 레지스트리 성격의 코드는 satisfies 도입 효과가 큽니다.
결론: satisfies는 “검증”과 “추론”의 타협을 끝낸다
TypeScript에서 오래된 고민은 “타입을 강하게 걸면 추론이 죽고, 추론을 살리면 검증이 약해진다”였습니다. TS 5.x의 satisfies는 이 균형을 아주 실용적으로 해결합니다.
: T처럼 타입을 고정하지 않고as T처럼 우기지 않으며- “이 형태가 맞는지”만 단단히 확인하고
- 실제 코딩 경험은 더 편하게 만든다
프로젝트에서 설정 객체나 매핑 테이블이 많다면, satisfies를 코드베이스 표준으로 삼는 것만으로도 타입 관련 버그와 리뷰 비용을 눈에 띄게 줄일 수 있습니다.