- Published on
TS 5.x satisfies로 타입 좁히기 실전 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 오래 쓰다 보면 “런타임 형태는 이게 맞는데, 타입을 맞추려다 추론이 죽는다”라는 순간을 자주 만납니다. 대표적으로 설정 객체, 라우트 테이블, 이벤트 핸들러 맵, 권한/피처 플래그처럼 정적 데이터(리터럴) 기반으로 로직이 파생되는 코드에서요.
as SomeType로 맞추면 검증이 약해지고(잘못된 키/값이 숨어도 통과),: SomeType으로 주석을 달면 리터럴 추론이 넓어져(예:'GET'이string으로), 이후 타입 좁히기/분기가 불편해집니다.
TS 4.9부터 도입된 satisfies(TS 5.x에서 더 널리 쓰이는 패턴)는 이 둘 사이를 절묘하게 메웁니다. 즉, “이 값은 이 타입을 만족해야 한다”를 검증하면서도, 값 자체의 구체적인 리터럴 타입은 최대한 유지합니다.
기본 개념과 문법 자체는 이미 널리 알려져 있으니, 여기서는 실전에서 바로 써먹는 타입 좁히기 중심 패턴을 모아봅니다. (기본 동작/주의점은 TS 5.6 satisfies로 타입 유지하며 검증하는 법도 함께 참고하면 좋습니다.)
satisfies가 “타입 좁히기”에 강한 이유
satisfies는 “표현식의 타입을 어떤 타입으로 바꾸지 않고”, 그 타입이 목표 타입을 만족하는지만 검사합니다.
const x: Target = expr→x의 타입은Target으로 고정(추론 손실 가능)const x = expr as Target→ 검사 약함(단언)const x = expr satisfies Target→x는expr의 타입(리터럴/구체 타입 유지), 동시에Target을 만족해야 함
이 성질 때문에, 이후에 keyof typeof x, 인덱싱, 분기, 제네릭 추론에서 좁혀진 리터럴 타입을 그대로 활용할 수 있습니다.
패턴 1) “객체 스키마는 검증, 키/값은 리터럴 유지” (설정/상수 테이블)
가장 흔한 패턴입니다. 예를 들어 앱 설정을 관리하는 객체가 있고, 일부 필드는 필수/옵션/형식이 정해져 있다고 합시다.
type AppConfig = {
env: "dev" | "staging" | "prod";
apiBaseUrl: string;
retry: {
max: number;
backoff: "fixed" | "exponential";
};
};
const config = {
env: "prod",
apiBaseUrl: "https://api.example.com",
retry: {
max: 3,
backoff: "exponential",
},
} satisfies AppConfig;
// config.env는 "prod" (리터럴)로 유지
// config.retry.backoff도 "exponential"로 유지
이렇게 해두면, 아래처럼 리터럴 기반 분기가 깔끔합니다.
function isProd(c: typeof config) {
return c.env === "prod";
}
if (config.env === "prod") {
// 여기서 env는 "prod"로 좁혀짐
}
반대로 const config: AppConfig = { ... }로 쓰면 config.env는 "dev" | "staging" | "prod"로 넓어져서, “이 환경이 확정된 상수”라는 정보를 잃습니다.
패턴 2) Record/맵 구조에서 키를 좁혀 안전한 인덱싱 만들기
이벤트/에러 코드/명령어 같은 “키-핸들러” 구조는 보통 Record<string, ...>로 시작했다가, 점점 키가 늘고 누락/오타가 생깁니다.
satisfies로 키의 집합을 강제하면서도, typeof로 키를 뽑아 좁힐 수 있습니다.
type EventName = "USER_CREATED" | "USER_DELETED";
type Handler = (payload: unknown) => void;
type HandlerMap = Record<EventName, Handler>;
const handlers = {
USER_CREATED: (p) => console.log("created", p),
USER_DELETED: (p) => console.log("deleted", p),
// USER_UPDATED: ... // 있으면 즉시 에러(허용되지 않은 키)
} satisfies HandlerMap;
type KnownEvent = keyof typeof handlers; // "USER_CREATED" | "USER_DELETED"
function dispatch(name: KnownEvent, payload: unknown) {
handlers[name](payload);
}
핵심은 handlers의 타입을 HandlerMap으로 덮어씌우지 않기 때문에 keyof typeof handlers가 정확히 좁혀진 키 집합을 유지한다는 점입니다.
패턴 3) “라우트 테이블”에서 메서드/경로를 리터럴로 유지하고 타입 안전한 빌더 만들기
프론트/백엔드 모두에서 라우트 정의는 정적 테이블로 두는 경우가 많습니다. satisfies는 여기서도 강력합니다.
type RouteDef = {
method: "GET" | "POST" | "PUT" | "DELETE";
path: `/${string}`;
};
type Routes = Record<string, RouteDef>;
const routes = {
getUser: { method: "GET", path: "/users/:id" },
createUser: { method: "POST", path: "/users" },
} satisfies Routes;
type RouteName = keyof typeof routes; // "getUser" | "createUser"
function buildUrl(name: RouteName, params: Record<string, string>) {
const { path } = routes[name];
return path.replace(/:([A-Za-z0-9_]+)/g, (_, key) => params[key] ?? `:${key}`);
}
여기서 routes.getUser.method는 "GET"으로 유지됩니다. 따라서 특정 라우트만 허용하는 유틸도 쉽게 만듭니다.
type GetRoutes = {
[K in keyof typeof routes]: (typeof routes)[K]["method"] extends "GET" ? K : never
}[keyof typeof routes];
function callGetRoute(name: GetRoutes) {
// name은 GET 라우트만
return routes[name].path;
}
: 주석으로 routes: Routes를 걸어버리면 method가 전부 "GET" | "POST" | ...로 넓어져 위 같은 좁히기가 크게 약해집니다.
패턴 4) 유니온을 만족시키되, 각 항목을 “구체적으로” 유지하기 (배열/리스트)
플러그인 목록, 미들웨어 목록처럼 배열로 관리할 때도 satisfies가 유용합니다.
type Plugin =
| { kind: "auth"; provider: "keycloak" | "cognito" }
| { kind: "cache"; strategy: "memory" | "redis" };
const plugins = [
{ kind: "auth", provider: "keycloak" },
{ kind: "cache", strategy: "redis" },
] satisfies Plugin[];
// 각 원소의 kind/provider/strategy가 리터럴로 유지
이렇게 두면 필터링 후 좁히기가 자연스럽습니다.
const authPlugins = plugins.filter((p) => p.kind === "auth");
// authPlugins의 원소 타입은 여전히 Plugin이라 추가 가드가 필요할 수 있음
function isAuth(p: Plugin): p is Extract<Plugin, { kind: "auth" }> {
return p.kind === "auth";
}
const onlyAuth = plugins.filter(isAuth);
// onlyAuth: { kind: "auth"; provider: ... }[]
satisfies는 “배열 전체를 Plugin[]로 고정”하지 않으면서도, 원소가 Plugin 형태를 만족하는지 검증해줍니다.
패턴 5) as const와의 조합: “너무 얼리지 말고, 필요한 만큼만”
as const는 모든 것을 리터럴 + readonly로 얼립니다. 때로는 과합니다. satisfies와 섞어 “검증 + 선택적 고정”을 만들 수 있습니다.
type FeatureFlags = {
[name: string]: { default: boolean; owner: string };
};
const flags = {
newCheckout: { default: false, owner: "payments" },
fastSearch: { default: true, owner: "search" },
} as const satisfies FeatureFlags;
type FlagName = keyof typeof flags; // "newCheckout" | "fastSearch"
as const로 키/값을 완전히 고정(리터럴 유지 + readonly)satisfies FeatureFlags로 구조 검증(필드 누락/타입 오류 방지)
단, as const를 붙이면 이후에 값을 변경하는 코드가 있으면 막힙니다. “진짜 상수 테이블”일 때만 쓰고, 변경 가능성이 있으면 satisfies만으로도 충분한 경우가 많습니다.
패턴 6) “초과 속성(excess property)”을 잡고, 동시에 추론을 살리기
객체 리터럴은 타입 지정 방식에 따라 초과 속성 검사가 달라져 실수로 필드가 섞이기도 합니다. satisfies는 객체 리터럴에 대한 구조 검증을 강하게 걸어주는 편이라, 설정/테이블에서 특히 도움이 됩니다.
type HttpClientOptions = {
timeoutMs: number;
retries?: number;
};
const opts = {
timeoutMs: 3_000,
retries: 2,
// timeotMs: 1000, // 오타가 있으면 즉시 에러로 잡힘
} satisfies HttpClientOptions;
이 패턴은 운영 장애로 이어질 수 있는 “조용한 오타”를 줄여줍니다. 예를 들어 인증 설정에서 redirectUri/redirectURL 같은 실수는 실제로 무한 리다이렉트를 만들기도 하죠. 그런 류의 문제는 별개 주제로 Keycloak OAuth 로그인 무한 리다이렉트 8가지 원인에서도 다뤘지만, 타입 레벨에서 오타를 조기에 잡는 것만으로 예방되는 케이스가 많습니다.
패턴 7) 제네릭 헬퍼와 결합: “정의는 느슨하게, 사용은 엄격하게”
실무에서는 정의부를 재사용하고 싶어서 제네릭 헬퍼를 만듭니다. satisfies는 이런 헬퍼와 결합할 때 특히 좋습니다.
예: 권한 스코프 정의를 만들고, 스코프별 설명/위험도를 강제.
type ScopeMeta = {
description: string;
risk: "low" | "medium" | "high";
};
type ScopeMap<TScopes extends string> = Record<TScopes, ScopeMeta>;
function defineScopes<T extends string>() {
return <TMap extends ScopeMap<T>>(m: TMap) => m;
}
const makeScopes = defineScopes<"read:user" | "write:user">();
const scopes = makeScopes({
"read:user": { description: "Read user profile", risk: "low" },
"write:user": { description: "Modify user profile", risk: "high" },
// "admin": ... // 허용 안 됨
} satisfies ScopeMap<"read:user" | "write:user">);
type Scope = keyof typeof scopes; // "read:user" | "write:user"
여기서는 makeScopes(...) 자체가 검증을 해주지만, satisfies를 함께 두면 “이 객체는 이 스코프맵을 만족해야 한다”가 코드에 더 명시적으로 남고, 리팩터링 시에도 의도가 분명해집니다.
실전 팁: satisfies를 도입할 때 자주 하는 실수
1) satisfies는 타입을 바꾸지 않는다
검증만 하고 타입은 그대로 유지합니다. 그래서 어떤 함수가 TargetType을 정확히 요구한다면, satisfies만으로는 인자 타입이 맞지 않을 수 있습니다.
type Target = { a: number };
const x = { a: 1, b: 2 } satisfies Target;
function needsTarget(t: Target) {}
needsTarget(x); // OK인 경우가 많지만, 상황에 따라(특히 제네릭/불변성) 조정 필요
대부분 구조적 타이핑이라 통과하지만, 제네릭 함수/불변성/리터럴 유지로 인해 기대와 다르게 보일 때는 Target으로의 변환이 아니라 함수 시그니처를 더 유연하게 만들거나, 필요한 지점에서만 좁혀서 전달하세요.
2) 너무 큰 타입에 satisfies를 걸면 “검증 비용”이 커진다
거대한 Routes/Config 타입에 한 번에 걸기보다, 도메인별로 쪼개서 satisfies를 적용하면 에러 메시지도 읽기 쉬워집니다.
3) noUncheckedIndexedAccess와 함께 쓰면 더 안전해진다
맵 인덱싱 시 undefined 가능성이 드러나므로, keyof typeof map 기반으로 키를 좁혀주는 패턴(위 패턴 2, 3)이 더 빛납니다.
결론: as를 줄이고, “데이터로부터 타입을 뽑는” 흐름을 만들기
TS 5.x에서 satisfies는 단순한 문법 설탕이 아니라, 정적 데이터(리터럴) → 타입 추론 → 안전한 분기/인덱싱으로 이어지는 개발 흐름을 강화하는 도구입니다.
- 설정/상수 테이블: 구조는 검증하고 값은 리터럴로 유지
- 이벤트/라우트/핸들러 맵:
keyof typeof로 안전한 키 집합 생성 - 유니온 배열: 항목 형태 검증 + 리터럴 유지
as const와 조합: 진짜 상수만 강하게 고정
기존에 as 단언으로 “일단 통과”시키던 부분을 satisfies로 바꾸는 것만으로도, 리팩터링 내성이 크게 올라가고 런타임 버그가 줄어듭니다. 특히 라우트/이벤트/설정처럼 변경이 잦은 영역부터 적용해보면 효과가 빠르게 체감됩니다.