- Published on
TS 5.5 const 타입 오류, satisfies로 안전하게 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 쓰다 보면 “값은 분명 맞는데 타입이 왜 이래?” 같은 순간이 자주 옵니다. 특히 TS 5.5로 올린 뒤 as const를 붙인 설정 객체나 라우팅 테이블, 이벤트 맵 같은 “정적 데이터”에서 타입 오류가 더 자주 보인다고 느끼는 경우가 많습니다. 실제로는 TS가 더 엄격해졌다기보다, 우리가 const 리터럴 타입을 과하게 좁혀버린 뒤 그 값을 넓은 타입(예: string, number, union) 으로 소비하려다 충돌하는 패턴이 표면화되는 경우가 많습니다.
이 글에서는 TS 5.5에서 자주 마주치는 “const 타입 오류”의 전형적인 형태를 재현하고, satisfies로 (1) 리터럴 추론은 유지하면서 (2) 원하는 스키마를 검증하고 (3) 불필요한 타입 단언(as)을 줄이는 해결법을 정리합니다.
문제의 핵심: as const가 타입을 너무 좁힌다
as const는 값 전체를 readonly + 리터럴 타입으로 고정합니다. 설정/매핑 테이블에서 매우 유용하지만, 그 결과 타입이 지나치게 좁아져서 다음과 같은 문제가 생깁니다.
- 어떤 함수는
string을 기대하는데, 우리는'GET' | 'POST'같은 리터럴 유니온을 넘기고 싶다(이건 보통 OK) - 반대로, 어떤 곳은
'GET' | 'POST'같은 제한된 유니온을 기대하는데,as const를 붙인 객체를 “넓은 타입”으로 강제 변환하면서 정보가 사라진다 - 혹은 객체의 값 타입이 리터럴로 고정되면서, 제네릭/인덱싱/맵핑 타입에서 “너무 좁다” 또는 “호환되지 않는다”가 발생한다
여기서 satisfies가 강력한 이유는:
- 검증(체크)은 지정한 타입으로 하되
- 표현식의 실제 추론 타입은 유지하기 때문입니다.
즉, as SomeType처럼 “타입을 바꿔치기”하지 않고, “이 값이 SomeType을 만족하는지”만 확인합니다.
재현: const 설정 객체에서 흔한 오류 패턴
예를 들어, API 라우트 테이블을 정적 객체로 만들고 싶다고 합시다.
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
type RouteSpec = {
method: Method
path: `/${string}`
auth: 'public' | 'user' | 'admin'
}
type Routes = Record<string, RouteSpec>
// 흔히 하는 패턴
const routes = {
health: { method: 'GET', path: '/health', auth: 'public' },
createUser: { method: 'POST', path: '/users', auth: 'admin' },
} as const
function registerRoutes(r: Routes) {
// ...
}
registerRoutes(routes)
이 코드는 상황에 따라 다음 류의 문제가 터질 수 있습니다.
routes는as const로 인해readonly가 되어Routes(mutable)와 호환되지 않음Record<string, RouteSpec>가 기대하는 구조와routes의 실제 추론 타입이 미묘하게 달라 오류- 해결하려고
as Routes를 붙이면, 오히려 리터럴 정보(예: 키/경로 템플릿 리터럴)를 잃어버려 이후 타입 안전성이 떨어짐
“그럼 readonly를 맞추면 되지 않나?”의 함정
다음처럼 바꾸면 일단 들어갈 수는 있습니다.
type RoutesRO = Readonly<Record<string, RouteSpec>>
registerRoutes(routes as unknown as RoutesRO)
하지만 이건 타입 단언으로 문제를 덮는 방식입니다. 잘못된 method, 잘못된 path가 들어가도 컴파일러가 놓칠 여지가 생깁니다.
해결: satisfies로 스키마 검증 + 리터럴 유지
routes를 “Routes 형태를 만족해야 한다”라고 선언하되, 타입 자체는 as const가 만든 리터럴 타입을 유지시키면 됩니다.
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
type RouteSpec = {
method: Method
path: `/${string}`
auth: 'public' | 'user' | 'admin'
}
type Routes = Record<string, RouteSpec>
const routes = {
health: { method: 'GET', path: '/health', auth: 'public' },
createUser: { method: 'POST', path: '/users', auth: 'admin' },
} as const satisfies Routes
function registerRoutes(r: Routes) {
// ...
}
registerRoutes(routes)
핵심 효과는 두 가지입니다.
- 검증:
routes가Routes를 만족하지 않으면 컴파일 타임에 에러가 납니다. - 추론 유지:
routes의 실제 타입은 여전히 키가'health' | 'createUser'로 남고, 각 값도 리터럴로 유지됩니다.
잘못된 값이 즉시 잡히는지 확인
const routes2 = {
bad1: { method: 'PATCH', path: '/x', auth: 'public' },
bad2: { method: 'GET', path: 'no-slash', auth: 'public' },
} as const satisfies Routes
'PATCH'는Method에 없으므로 에러path는`/${string}`을 만족해야 하므로'no-slash'에서 에러
as Routes로 단언해버리면 이런 오류를 놓칠 수 있지만, satisfies는 검증을 강제합니다.
TS 5.5에서 특히 자주 보이는 “const 타입 오류” 케이스 3가지
1) readonly vs mutable 불일치
as const는 모든 프로퍼티를 readonly로 만듭니다. 그런데 소비하는 쪽 타입이 mutable이면 충돌합니다.
해결 전략:
- 소비 타입을
Readonly<...>로 바꾸기 - 혹은 입력 파라미터를
r: Routes가 아니라r: Readonly<Routes>처럼 받기 - 정적 테이블은 보통 불변이므로, 소비자 타입을 불변으로 모델링하는 게 더 자연스럽습니다.
type Routes = Record<string, RouteSpec>
type RoutesInput = Readonly<Routes>
function registerRoutes(r: RoutesInput) {}
const routes = {
health: { method: 'GET', path: '/health', auth: 'public' },
} as const satisfies Routes
registerRoutes(routes)
여기서도 satisfies Routes로 “형태는 Routes여야 한다”를 검증하고, 실제 전달은 Readonly<Routes>로 받는 식으로 조합할 수 있습니다.
2) 값 타입이 너무 좁아져 제네릭에서 깨짐
예: 특정 키로 접근했을 때 값을 넓은 타입으로 쓰고 싶은데, 리터럴로 너무 좁아서 연산이 제한되는 경우가 있습니다.
이때는 satisfies로 스키마 검증을 하되, 필요한 곳에서만 적절히 widen(확장)시키는 게 좋습니다.
const limits = {
retry: 3,
timeoutMs: 1500,
} as const satisfies Record<string, number>
// 여기서는 number로 넓혀서 사용
const timeout: number = limits.timeoutMs
3) 객체 키/값 리터럴 정보를 유지한 채로 “스키마만 체크”하고 싶을 때
as SomeType은 리터럴 정보를 날려버립니다.
type EventMap = {
login: { userId: string }
logout: { userId: string }
}
// 나쁜 예: 단언으로 리터럴 정보 손실 가능
const eventsBad = {
login: { userId: 'x' },
logout: { userId: 'y' },
} as EventMap
// 좋은 예: satisfies로 검증 + 리터럴 유지
const events = {
login: { userId: 'x' },
logout: { userId: 'y' },
} satisfies EventMap
이 패턴은 라우팅, 권한 매트릭스, 피처 플래그, 폼 스키마 등에서 특히 효과적입니다.
실전 패턴: “const 테이블 + 타입 안전한 접근 함수” 만들기
정적 테이블을 만들고, 그 키를 기반으로 안전한 접근 함수를 만들 때 satisfies가 빛납니다.
type Role = 'guest' | 'user' | 'admin'
type Permission = {
canRead: boolean
canWrite: boolean
}
type PermissionTable = Record<Role, Permission>
const permissions = {
guest: { canRead: true, canWrite: false },
user: { canRead: true, canWrite: true },
admin: { canRead: true, canWrite: true },
} as const satisfies PermissionTable
function getPermission(role: Role) {
return permissions[role]
}
const p = getPermission('user')
// p는 { readonly canRead: true; readonly canWrite: true } 같은 리터럴에 가깝게 유지될 수 있음
permissions는PermissionTable을 만족해야 하므로 role 누락/오타가 잡힙니다.- 동시에
as const로 인해 각 role의 권한 값이 리터럴로 유지되어, 더 강한 타입 추론이 가능합니다.
satisfies 사용 시 주의점
1) satisfies는 타입을 “바꾸지” 않는다
satisfies는 “체크만” 합니다. 그래서 어떤 변수의 타입을 의도적으로 넓히고 싶다면 별도 선언이 필요합니다.
const x = { a: 1, b: 2 } satisfies Record<string, number>
// x의 타입은 여전히 { a: number; b: number }에 가깝게 유지됨
const y: Record<string, number> = { a: 1, b: 2 }
// y는 Record<string, number>로 넓혀짐
둘은 목적이 다릅니다.
satisfies: 리터럴/구체 타입 유지 + 스키마 검증- 타입 주석(
: T): 변수 타입을 T로 “결정”
2) as const satisfies ... 순서
일반적으로 as const satisfies T를 많이 씁니다.
as const로 리터럴 고정satisfies T로 스키마 검증
상황에 따라 satisfies만으로도 충분합니다(리터럴 고정이 필요 없으면).
팀 적용 가이드: 어떤 경우에 satisfies를 표준으로 둘까?
다음 조건이면 satisfies를 기본 패턴으로 추천합니다.
- 코드 안에 “정적 데이터 테이블”이 있고(라우트, 이벤트, 권한, 피처 플래그)
- 그 데이터가 특정 스키마를 반드시 따라야 하며
- 동시에 키/값 리터럴 정보를 유지해 타입 추론을 풍부하게 하고 싶고
as SomeType단언을 남발하고 싶지 않을 때
TS 5.5로 올리면서 기존에 숨어있던 타입 단언/readonly 불일치가 드러난 팀이라면, satisfies 도입만으로도 타입 안정성이 크게 올라갑니다.
부록: 디버깅 관점에서의 체크리스트
const 타입 오류를 만나면 아래를 순서대로 확인하면 빠릅니다.
as const때문에 readonly가 되었나? 소비 타입이 mutable이면 충돌 가능as SomeType단언으로 타입 정보를 잃었나? 리터럴 기반 추론이 깨지면 연쇄 오류가 납니다- “검증”이 목적이면
satisfies로 바꿀 수 있나? 타입 단언보다 안전합니다
운영 환경에서 문제를 10분 안에 좁혀가는 진단 루틴이 중요하듯(예: GitHub Actions 캐시가 안 먹을 때 - key·restore-keys·권한, Chrome INP 급등? Long Task 추적·해결 가이드), 타입 오류도 “원인 분류 → 최소 재현 → 안전한 패턴으로 치환” 순서로 접근하면 해결 속도가 빨라집니다.
결론
TS 5.5에서 마주치는 많은 “const 타입 오류”는 결국 as const로 인해 타입이 과도하게 좁혀지거나 readonly가 전파되면서, 소비하는 타입과 충돌하는 데서 시작합니다. 이때 satisfies는
- 타입 단언 없이(또는 최소화하며)
- 스키마 적합성을 강제하고
- 리터럴 타입 추론을 유지
하는 균형 잡힌 해법입니다.
정적 테이블을 만드는 코드에서 as const를 쓰고 있다면, 다음 리팩터링을 먼저 적용해 보세요.
const table = {
// ...
} as const satisfies SomeSchema
대부분의 경우, 타입 오류는 줄고 타입 정보는 더 풍부해질 것입니다.