- Published on
TS 5.7 satisfies로 타입 추론 깨짐 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공용 타입을 설계하다 보면 “타입은 맞는데 추론이 뭉개지는” 순간이 자주 옵니다. 대표적으로 객체 리터럴에 타입 주석을 붙이는 순간 리터럴 타입이 넓어져 버리고, 키 유니온이나 값의 리터럴 정보가 사라져 downstream 코드에서 자동완성/오류 검출이 약해집니다.
TypeScript의 satisfies는 이 문제를 해결하기 위한 도구입니다. 핵심은 값은 그대로 두고(리터럴 추론 유지), 그 값이 특정 타입 조건을 만족하는지만 검증한다는 점입니다. TS 5.7에서도 이 패턴은 더욱 중요해졌고, as const/제네릭 헬퍼/Record 조합으로 억지로 해결하던 부분을 더 깔끔하게 정리할 수 있습니다.
아래에서는 satisfies가 왜 “타입 추론 깨짐”을 막는지, 어떤 상황에서 특히 효과적인지, 그리고 실전에서 자주 쓰는 레시피를 코드로 정리합니다.
왜 타입 주석이 추론을 깨뜨릴까
객체 리터럴에 타입 주석을 달면, TypeScript는 그 객체를 “그 타입”으로 취급합니다. 그 과정에서 리터럴 타입(예: 'GET', 200, 'users.list')이 넓은 타입(예: string, number)으로 승격되는 일이 잦습니다.
예를 들어 API 라우트 테이블을 만든다고 해봅시다.
type Route = {
method: "GET" | "POST";
path: string;
};
const routes: Record<string, Route> = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
};
이 코드는 얼핏 좋아 보이지만, routes의 키는 Record<string, Route> 때문에 사실상 string으로 넓어지고, routes.listUsers.method 같은 값도 “리터럴 정보”가 줄어들어 더 정밀한 타입 계산이 어려워집니다.
특히 다음 같은 패턴에서 문제가 커집니다.
keyof typeof routes로 키 유니온을 뽑아 라우팅/권한/번역 키를 만들고 싶다routes[key].method가 실제로는'GET'인지'POST'인지에 따라 분기 타입을 만들고 싶다path를 템플릿 리터럴 타입으로 제한하고 싶다
이때 satisfies는 검증은 하되 추론은 건드리지 않는 방식으로 해결합니다.
satisfies 한 줄 정의
expr satisfies T는expr이T에 할당 가능함을 검사합니다.- 하지만
expr의 타입을T로 “바꾸지” 않습니다.
즉, 타입 주석 : T와 달리 표현식의 고유한 추론 결과를 유지합니다.
TS 5.7에서의 실전 레시피 1: 라우트/설정 테이블
아까 예시를 satisfies로 바꾸면 다음처럼 됩니다.
type Route = {
method: "GET" | "POST";
path: `/${string}`;
};
const routes = {
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
// typo: { method: "FETCH", path: "users" }, // 즉시 오류
} satisfies Record<string, Route>;
type RouteKey = keyof typeof routes;
// ^? "listUsers" | "createUser"
여기서 얻는 이점은 다음과 같습니다.
routes의 키는 리터럴로 유지되어RouteKey가 정확한 유니온이 됩니다.- 각 값은
Route조건을 만족해야 하므로method/path가 틀리면 컴파일 타임에 잡힙니다. path를`/${string}`처럼 템플릿 리터럴 타입으로 강제할 수 있습니다.
Record<string, Route>는 “키는 어떤 문자열이든 가능”이라는 의미지만, satisfies는 현재 객체가 그 규칙을 만족하는지만 보고, 실제 키 타입은 "listUsers" | "createUser"로 남겨줍니다.
레시피 2: 값 리터럴을 살려서 분기 타입 만들기
method 값이 리터럴로 유지되면, 그걸 기반으로 “GET 라우트만 뽑기” 같은 타입 연산이 쉬워집니다.
type Routes = typeof routes;
type KeysByMethod<M extends Route["method"]> = {
[K in keyof Routes]: Routes[K]["method"] extends M ? K : never
}[keyof Routes];
type GetRouteKeys = KeysByMethod<"GET">;
// ^? "listUsers"
만약 routes를 : Record<string, Route>로 주석 처리했다면, Routes[K]["method"]가 리터럴이 아닌 "GET" | "POST"로 뭉개져 위 연산이 원하는 대로 동작하지 않거나, 결과가 지나치게 넓어질 수 있습니다.
레시피 3: as const와의 역할 분리
많은 코드베이스가 다음처럼 as const로 해결을 시도합니다.
const statusMap = {
ok: 200,
notFound: 404,
} as const;
as const는 리터럴을 강하게 고정하지만, 구조 검증은 하지 않습니다. 즉, 잘못된 형태여도 타입이 “고정된 채로” 통과할 수 있습니다.
반대로 satisfies는 구조 검증에 강합니다. 둘을 함께 쓰면 “리터럴 유지 + 스키마 검증”을 동시에 얻습니다.
type StatusMap = Record<string, number>;
const statusMap = {
ok: 200,
notFound: 404,
// bad: "500", // 문자열이면 오류
} as const satisfies StatusMap;
type StatusKey = keyof typeof statusMap;
// ^? "ok" | "notFound"
정리하면:
- 리터럴을 강하게 고정해야 한다면
as const - 특정 타입/스키마를 만족하는지 검증해야 한다면
satisfies - 둘 다 필요하면
as const satisfies ...조합
레시피 4: 이벤트/액션 타입에서 추론 깨짐 막기
Redux 스타일 액션, 도메인 이벤트에서도 동일한 문제가 생깁니다. 액션 크리에이터 테이블을 만들 때 타입 주석 때문에 type 필드가 string으로 넓어지는 경우가 흔합니다.
type Action =
| { type: "user/created"; payload: { id: string } }
| { type: "user/deleted"; payload: { id: string } };
const actions = {
created: (id: string) => ({ type: "user/created", payload: { id } }),
deleted: (id: string) => ({ type: "user/deleted", payload: { id } }),
} satisfies Record<string, (...args: any[]) => Action>;
type ActionKey = keyof typeof actions;
// ^? "created" | "deleted"
이렇게 해두면 액션 함수 구현이 Action 유니온을 만족하는지 보장하면서도, 키 유니온(예: "created" | "deleted")을 잃지 않습니다.
레시피 5: satisfies로 “과잉 속성”을 잡고 싶다면
객체 리터럴은 타입에 없는 속성을 넣으면 보통 초과 속성 검사로 잡힙니다. 그런데 중간 변수로 한 번 받거나, 타입 단언을 쓰면 그 안전망이 사라질 수 있습니다.
as SomeType 같은 단언은 검증을 건너뛰므로 특히 위험합니다. 이때 satisfies는 단언 없이도 “이 형태가 맞는지”를 검사할 수 있어, 설정 파일/매니페스트 성격의 코드에서 유용합니다.
type FeatureFlags = {
enableNewNav: boolean;
enableBilling: boolean;
};
const flags = {
enableNewNav: true,
enableBilling: false,
// enableBiling: true, // 오타도 즉시 오류
} satisfies FeatureFlags;
실전 팁: 어디에 satisfies를 붙이면 좋은가
다음 조건에 해당하면 적용 우선순위가 높습니다.
- 객체 리터럴 테이블을 만들어
keyof typeof를 쓰는 곳- 라우트, 권한, 번역 키, 에러 코드, 상태 코드, 피처 플래그
- 값의 리터럴을 기반으로 타입 연산을 하는 곳
- 템플릿 리터럴 타입, 조건부 타입, 매핑 타입
Record<string, ...>를 쓰고 있는데 키 유니온이 필요했던 곳as const만으로는 구조 검증이 부족한 곳
TS 5.7 마이그레이션 관점에서의 의미
TS 버전이 올라가면서 추론/검사 규칙이 미세하게 바뀌면, “예전엔 우연히 통과하던” 코드가 깨지기도 하고, 반대로 “예전엔 타입이 너무 넓어서 놓치던 버그”가 드러나기도 합니다. satisfies를 습관적으로 적용해두면 다음 효과가 있습니다.
- 타입 주석으로 인한 리터럴 손실을 줄여, 버전 업 시 연쇄적인 타입 변경을 완화
- 설정/테이블 코드를 선언적으로 유지하면서도 스키마 위반을 컴파일 타임에 차단
as단언을 줄여 타입 안정성을 높임
TS 업그레이드 과정에서 선언 파일 생성/격리 빌드 옵션 때문에 타입이 더 엄격해지는 경우도 많습니다. 관련해서는 TS 5.5+ isolatedDeclarations 오류 해결 가이드도 함께 보면, “왜 갑자기 타입이 깨지지?” 같은 상황을 더 체계적으로 정리할 수 있습니다.
자주 하는 실수
1) satisfies를 “캐스팅”으로 착각하기
expr satisfies T는 expr의 타입을 T로 바꾸지 않습니다. 따라서 T가 제공하는 좁은 타입 정보(예: 특정 프로퍼티가 선택적이라든지)를 expr가 자동으로 따라가지는 않습니다. 오히려 추론된 타입이 유지됩니다.
필요하다면 satisfies로 검증하고, 사용하는 쪽에서 별도의 타입 유틸로 정규화하거나, 명시적 타입을 도입해야 합니다.
2) 너무 넓은 타입에 satisfies를 걸어 효과가 없는 경우
예를 들어 satisfies Record<string, any> 같은 건 검증 가치가 거의 없습니다. satisfies는 “검증할 스키마”가 있을 때 빛납니다.
3) as const를 남용해 수정이 어려워지는 경우
as const는 값 전체를 깊게 readonly로 만들 수 있어 편하지만, 이후 조작/병합이 필요한 구조라면 오히려 불편해집니다. 이때는 satisfies만으로도 충분한지 먼저 판단하세요.
예제: 권한 매트릭스에서 키/값 추론 유지
권한 설계는 키 유니온이 매우 중요합니다. 아래는 라우트별 필요한 권한을 선언하고, 그 키를 기반으로 타입 안전한 체크 함수를 만드는 예시입니다.
type Role = "admin" | "member" | "guest";
type PermissionSpec = {
roles: readonly Role[];
};
const permissions = {
"users:list": { roles: ["admin", "member"] },
"users:create": { roles: ["admin"] },
"health:read": { roles: ["admin", "member", "guest"] },
} as const satisfies Record<string, PermissionSpec>;
type PermissionKey = keyof typeof permissions;
function canAccess(role: Role, key: PermissionKey) {
return permissions[key].roles.includes(role);
}
여기서 PermissionKey가 정확한 문자열 리터럴 유니온으로 유지되기 때문에, canAccess("member", "users:cretae") 같은 오타는 바로 잡힙니다.
모노레포에서 이런 테이블이 커지면 CI에서 타입 오류가 폭발적으로 늘어나는 경우가 있는데, 워크플로우 설계 측면은 GitHub Actions 모노레포 CI/CD 워크플로우 폭증 막기처럼 “검증 범위를 어떻게 나눌지”도 함께 고민하면 좋습니다.
결론
TS 5.7 환경에서 satisfies는 “타입 안전성”과 “추론 품질”을 동시에 잡기 위한 기본 도구에 가깝습니다. 객체 리터럴을 타입 주석으로 덮어버려 키 유니온과 리터럴 값 정보를 잃는 순간, 타입 시스템의 장점(자동완성, 정적 검증, 분기 타입)이 급격히 약해집니다.
정리하면 다음 한 줄로 기억하면 됩니다.
- 타입 주석
: T는 타입을 강제한다(추론이 뭉개질 수 있음) satisfies T는 조건만 검증한다(추론을 유지)
라우트/권한/설정/이벤트 테이블처럼 “선언적으로 적고, 키를 타입으로 재활용하는” 코드에서는 satisfies를 우선적으로 적용해두면 TS 업그레이드 시에도 안정적으로 유지보수할 수 있습니다.