- Published on
TS 5.x satisfies로 타입 오류 줄이는 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 SDK 설정, 라우팅 테이블, 이벤트 맵, 상태 머신 전이표처럼 “객체 리터럴을 크게 만들어 두고 나중에 꺼내 쓰는” 코드가 늘어날수록 TypeScript에서 흔히 겪는 문제가 있습니다.
- 객체를
: SomeType로 주석 처리하면 값의 구체 정보가 사라져(widening) 이후 코드에서 추론이 약해짐 - 반대로
as const를 과하게 쓰면 너무 좁아져 재사용이 불편해짐 - 결국
as단언이 늘고, 실제 버그를 막아줄 타입 시스템이 “통과 의례”가 됨
TS 5.x에서 satisfies는 이 균형을 실무적으로 해결해줍니다. 핵심은 간단합니다.
satisfies는 “이 값이 특정 타입 요구사항을 만족하는지”만 검증합니다.- 동시에 값 자체의 추론 결과(구체 타입) 는 최대한 유지합니다.
아래에서는 satisfies를 어디에 쓰면 타입 오류가 줄어드는지, 어떤 패턴에서 특히 효과적인지 실전 예제로 정리합니다.
관련해서 선언 파일 생성/빌드에서 타입이 까다로워지는 경우는 TS 5.5 isolatedDeclarations 오류 해결 가이드도 함께 보면 좋습니다.
satisfies가 해결하는 대표 문제: 주석 타입의 부작용
먼저 전형적인 문제를 보겠습니다.
: Type 주석은 검증이 아니라 “강제 캐스팅”에 가깝다
type Route = {
path: string;
method: "GET" | "POST";
};
type Routes = Record<string, Route>;
const routes: Routes = {
home: { path: "/", method: "GET" },
login: { path: "/login", method: "POST" },
};
// 여기서 routes.home.method의 타입은 "GET" | "POST"로 넓어짐
// (실제로는 "GET"인데도)
routes.home.method는 분명히 리터럴 값이 "GET"인데, Routes로 주석을 달아버리면 "GET" | "POST"로 넓어집니다. 이게 왜 문제냐면, 이후 코드에서 “이 라우트는 GET 전용” 같은 분기/최적화/정적 검증이 약해지고, 종종 불필요한 런타임 체크나 단언으로 이어집니다.
satisfies는 “검증만” 하고 추론은 유지한다
type Route = {
path: string;
method: "GET" | "POST";
};
type Routes = Record<string, Route>;
const routes = {
home: { path: "/", method: "GET" },
login: { path: "/login", method: "POST" },
} satisfies Routes;
// routes.home.method 타입은 "GET" (리터럴)로 유지될 가능성이 커짐
// 동시에 전체 객체는 Routes 요구사항을 만족해야 함
정리하면:
: Routes는 “이 변수는 Routes다”라고 타입을 덮어쓰기satisfies Routes는 “이 값이 Routes 조건을 만족하는지 검증만”
이 차이가 실무에서 타입 오류를 줄이는 출발점입니다.
패턴 1: 설정 객체(Feature flag, 환경설정)에서 누락/오타 잡기
실무에서 설정 객체는 자주 바뀌고, 키 오타나 누락이 잦습니다.
type AppConfig = {
env: "dev" | "prod";
apiBaseUrl: string;
enableNewCheckout: boolean;
};
// 흔한 실수: : AppConfig로 선언하고 값은 대충 맞추기
// 또는 as AppConfig로 단언해버리기
const config = {
env: "prod",
apiBaseUrl: "https://api.example.com",
enableNewCheckout: true,
// enableNewChekcout: true, // 오타가 있어도 단언이면 통과할 수 있음
} satisfies AppConfig;
여기서 satisfies의 이점은:
- 필드 누락/타입 불일치가 즉시 에러
- 불필요한
as AppConfig단언을 줄임 env같은 리터럴은 가능한 한 좁게 유지되어 후속 분기에서 유리
추가로 “키를 제한하고 싶다”면 satisfies와 Record를 조합합니다.
type Env = "dev" | "stage" | "prod";
type EnvConfig = {
apiBaseUrl: string;
timeoutMs: number;
};
const envConfigs = {
dev: { apiBaseUrl: "http://localhost:3000", timeoutMs: 5_000 },
stage: { apiBaseUrl: "https://stage-api.example.com", timeoutMs: 8_000 },
prod: { apiBaseUrl: "https://api.example.com", timeoutMs: 10_000 },
// prdo: { ... } // 오타 키는 즉시 잡힘
} satisfies Record<Env, EnvConfig>;
패턴 2: 이벤트/커맨드 핸들러 맵에서 시그니처 불일치 제거
이벤트 기반 코드에서 “이 이벤트는 payload가 무엇인지”를 타입으로 고정하고, 핸들러 맵을 객체로 관리하는 경우가 많습니다.
type Events = {
"user.created": { userId: string; email: string };
"user.deleted": { userId: string };
};
type HandlerMap = {
[K in keyof Events]: (payload: Events[K]) => Promise<void>;
};
const handlers = {
"user.created": async (p) => {
// p는 { userId, email }
},
"user.deleted": async (p) => {
// p는 { userId }
},
// "user.delated": async () => {}, // 키 오타 즉시 에러
// "user.deleted": async (p: { id: string }) => {}, // payload 타입 불일치 에러
} satisfies HandlerMap;
여기서 handlers: HandlerMap = { ... }로 해도 검증은 되지만, satisfies가 좋은 이유는 “핸들러 구현 내부에서의 추론”이 더 자연스러운 경우가 많고, 불필요한 타입 어노테이션을 줄이기 때문입니다.
패턴 3: 라우팅/권한 테이블에서 리터럴 정보를 유지해 분기 오류 줄이기
권한 체크는 “문자열 상수 집합”과 “라우트 메타데이터”가 엮이면서 런타임 버그가 자주 발생합니다.
type Role = "guest" | "member" | "admin";
type RouteMeta = {
path: string;
roles: readonly Role[];
};
const routeMeta = {
home: { path: "/", roles: ["guest", "member", "admin"] },
admin: { path: "/admin", roles: ["admin"] },
me: { path: "/me", roles: ["member", "admin"] },
} satisfies Record<string, RouteMeta>;
function canAccess(routeKey: keyof typeof routeMeta, role: Role) {
return routeMeta[routeKey].roles.includes(role);
}
포인트:
routeMeta자체는Record<string, RouteMeta>를 만족해야 하므로 구조가 깨지면 즉시 컴파일 에러- 동시에
keyof typeof routeMeta로 라우트 키를 안전하게 다룸 roles배열이 리터럴 기반으로 유지되면, 특정 라우트가admin만 허용 같은 조건을 정적으로 활용하기도 쉬움
패턴 4: “키는 제한, 값은 다양한” 맵을 안전하게 만들기
예를 들어 결제 상태 전이표처럼 키 조합이 제한된 테이블이 있을 때, satisfies는 누락을 아주 잘 잡습니다.
type PaymentState = "created" | "authorized" | "captured" | "canceled";
type TransitionTable = {
[S in PaymentState]: readonly PaymentState[];
};
const transitions = {
created: ["authorized", "canceled"],
authorized: ["captured", "canceled"],
captured: [],
canceled: [],
// authorzied: ["captured"], // 오타 키는 에러
// created: ["refunded"], // 허용되지 않은 상태 값 에러
} satisfies TransitionTable;
이런 테이블은 시간이 지나면 상태가 늘고, 누락이 생기기 쉽습니다. satisfies는 “테이블이 완전한지”를 강제하면서도 각 값의 구체적인 배열 리터럴 정보는 유지합니다.
MSA에서 보상 트랜잭션이나 상태 전이를 다루는 글을 찾는다면 DDD에서 분산 트랜잭션 없이 SAGA 구현하기도 같이 보면 전이표/상태 설계 관점에서 도움 됩니다.
패턴 5: as const와 satisfies 조합으로 “정적 데이터” 품질 올리기
정적 데이터(상수 카탈로그, 코드북, 국가 코드 등)는 보통 as const를 붙입니다. 문제는 as const만 붙이면 “형태 검증”이 약해져도 지나갈 수 있다는 점입니다.
satisfies를 같이 쓰면:
as const로 리터럴을 고정satisfies로 스키마를 검증
type Country = {
code: string;
name: string;
currency: "KRW" | "USD" | "JPY";
};
const countries = [
{ code: "KR", name: "Korea", currency: "KRW" },
{ code: "US", name: "United States", currency: "USD" },
// { code: "JP", name: "Japan", currency: "YEN" }, // 에러
] as const satisfies readonly Country[];
// countries[0].code는 "KR" 같은 리터럴로 유지
이 패턴은 프론트엔드에서 옵션 리스트, 백엔드에서 에러 코드 카탈로그 같은 곳에 특히 유용합니다.
자주 헷갈리는 포인트 정리
satisfies는 타입을 “바꾸지” 않는다
satisfies는 타입 어서션이 아닙니다. 즉, 값의 타입을 SomeType으로 만들어주는 문법이 아니라 “컴파일 타임 검증”입니다.
- 어떤 값이
satisfies SomeType을 통과해도, 그 값의 타입이 곧바로SomeType으로 고정되지는 않습니다. - 대신 가능한 한 구체적인 타입이 유지됩니다.
잉여 속성(excess property) 검사를 더 잘 활용할 수 있다
객체 리터럴은 TypeScript에서 잉여 속성 검사가 걸리기 좋은 지점입니다. satisfies를 쓰면 “형태를 만족하는지”를 객체 리터럴 단계에서 강하게 검사할 수 있어, 오타/불필요 필드를 초기에 잡는 데 도움이 됩니다.
언제 : Type가 더 나은가
반대로 “이 값은 앞으로 무조건 이 인터페이스로만 취급할 거야” 같은 경우(추상화 경계를 만들고 싶을 때)는 : Type가 더 의도가 명확합니다.
- 라이브러리 외부로 노출하는 exported API
- 구현 세부 타입을 숨기고 싶은 모듈 경계
이런 경우는 satisfies를 남발하기보다, 경계에서는 명시 타입을 두고 내부 구현에서 satisfies로 품질을 올리는 식이 균형이 좋습니다.
실전 체크리스트: satisfies를 도입할 곳
다음 중 하나라도 해당되면 satisfies 도입 효과가 큽니다.
- 설정 객체가 크고 자주 변경된다
- 이벤트/커맨드 이름과 payload 타입을 맵으로 관리한다
- 라우팅 테이블, 권한 테이블, 상태 전이표 같은 “정적 테이블”이 있다
as SomeType단언이 늘어나고 있다- 객체 타입 주석 때문에 리터럴 정보가 사라져 분기/추론이 약해진다
마무리: 타입 오류를 줄이는 핵심은 “검증과 추론을 분리”하는 것
TypeScript에서 타입 오류를 줄이는 가장 현실적인 방법 중 하나는, 타입 시스템을 “단언으로 통과”시키는 게 아니라 “초기에 검증”하도록 구조를 바꾸는 것입니다.
TS 5.x의 satisfies는 그 목적에 정확히 맞습니다.
- 객체 리터럴을 만들 때 스키마를 만족하는지 강하게 검증
- 동시에 값의 구체적인 리터럴 타입을 유지
- 결과적으로 불필요한 단언과 런타임 방어 코드가 줄어듦
추가로 TS 5.5에서 추론/좁히기와 관련해 애매한 케이스를 겪는다면 TS 5.5에서 const인데 narrowing 안될 때 해결법도 함께 참고하면, satisfies와 조합했을 때의 타입 좁히기 감각을 더 빨리 잡을 수 있습니다.