- Published on
TS 5.x satisfies vs as const - 타입 깨짐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 설정, 라우트 테이블, 이벤트 핸들러 맵처럼 "값"에서 타입을 뽑아 쓰는 코드가 많아질수록, TypeScript에서 자주 만나는 문제가 있습니다.
Record<string, T>같은 넓은 타입을 붙이는 순간 리터럴 타입이string으로 깨짐- 반대로
as const를 남발하면 모든 것이readonly로 굳어져 수정/합성이 어려워짐 - 객체 리터럴의 오타나 누락을 잡고 싶지만, 추론도 유지하고 싶음
TS 5.x에서 satisfies는 이 딜레마를 꽤 우아하게 풀어줍니다. 하지만 satisfies가 만능은 아니고, as const가 더 적합한 경우도 명확히 존재합니다. 이 글에서는 두 문법의 차이를 “타입 깨짐” 관점에서 정리하고, 실무에서 바로 써먹을 수 있는 패턴을 코드로 보여드립니다.
관련 글로 satisfies 자체를 더 깊게 다룬 글도 있으니 함께 보면 좋습니다: TS 5.x satisfies로 타입추론 깨짐 해결하기
as const와 satisfies의 핵심 차이
as const는 “값을 최대한 좁히고 고정”한다
as const는 리터럴을 최대한 좁은 타입으로 만들고, 객체/배열의 속성을 readonly로 고정합니다.
- 장점: 값 기반 유니온 추출이 매우 강력함
- 단점:
readonly가 전파되어 이후 조작이 불편해짐
const STATUS = {
ACTIVE: "active",
INACTIVE: "inactive",
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
// "active" | "inactive"
satisfies는 “검증은 하되, 추론은 유지”한다
expr satisfies Type은 expr이 Type을 만족하는지 체크하지만, 결과 타입을 Type으로 강제 캐스팅하지 않습니다. 즉,
- 타입 오류를 잡되
- 원래의 더 구체적인(좁은) 추론을 유지
type StatusMap = Record<string, string>;
const STATUS = {
ACTIVE: "active",
INACTIVE: "inactive",
} satisfies StatusMap;
type Status = (typeof STATUS)[keyof typeof STATUS];
// "active" | "inactive" (리터럴 유지)
여기서 중요한 포인트는 : StatusMap 주석 타입과의 차이입니다.
type StatusMap = Record<string, string>;
const STATUS_ANNOTATED: StatusMap = {
ACTIVE: "active",
INACTIVE: "inactive",
};
type StatusA = (typeof STATUS_ANNOTATED)[keyof typeof STATUS_ANNOTATED];
// string (리터럴 깨짐)
: 타입 주석은 “이 변수는 앞으로 이 타입으로 취급할 거야”에 가깝고, satisfies는 “이 값이 이 조건을 만족하는지만 확인할게”에 가깝습니다.
타입 깨짐이 발생하는 대표 케이스
케이스 1: Record<string, ...>로 선언하는 순간 리터럴이 날아감
라우트/권한/이벤트 이름 맵을 만들 때 흔히 이렇게 씁니다.
type Route = {
path: string;
auth: "public" | "private";
};
type RouteTable = Record<string, Route>;
const ROUTES: RouteTable = {
home: { path: "/", auth: "public" },
admin: { path: "/admin", auth: "private" },
};
type RouteKey = keyof typeof ROUTES;
// string (원래는 "home" | "admin"을 기대)
Record<string, ...>는 키를 string으로 강제하기 때문에 keyof typeof ROUTES도 string이 됩니다. 이게 바로 “타입 깨짐”의 전형입니다.
해결: satisfies로 검증만 하고 키 유니온은 유지
type Route = {
path: string;
auth: "public" | "private";
};
type RouteTable = Record<string, Route>;
const ROUTES = {
home: { path: "/", auth: "public" },
admin: { path: "/admin", auth: "private" },
} satisfies RouteTable;
type RouteKey = keyof typeof ROUTES;
// "home" | "admin"
여기서 얻는 이점은 두 가지입니다.
- 값이
Route를 만족하는지(예:auth오타) 컴파일 타임에 잡음 - 키 유니온이 유지되어
RouteKey가 정확해짐
케이스 2: 값 유니온을 뽑아 쓰는데 string으로 넓어짐
예를 들어 이벤트 이름을 객체로 관리하고 싶다고 합시다.
type EventNameMap = Record<string, string>;
const EVENTS: EventNameMap = {
USER_CREATED: "user.created",
USER_DELETED: "user.deleted",
};
type EventName = (typeof EVENTS)[keyof typeof EVENTS];
// string
이 경우 as const는 해결되지만 readonly가 따라옵니다.
const EVENTS = {
USER_CREATED: "user.created",
USER_DELETED: "user.deleted",
} as const;
type EventName = (typeof EVENTS)[keyof typeof EVENTS];
// "user.created" | "user.deleted"
해결: satisfies로 리터럴 유지 + 형태 검증
type EventNameMap = Record<string, string>;
const EVENTS = {
USER_CREATED: "user.created",
USER_DELETED: "user.deleted",
} satisfies EventNameMap;
type EventName = (typeof EVENTS)[keyof typeof EVENTS];
// "user.created" | "user.deleted"
이제 EVENTS는 readonly로 얼지 않으면서도, 값 유니온은 살아 있습니다.
satisfies가 특히 강한 패턴 3가지
1) 객체 리터럴의 “오타/누락”을 잡으면서 추론 유지
as const만 쓰면 형태 검증이 약해질 수 있습니다. 예를 들어 아래에서 autn 같은 오타는 구조적 타입 체크가 없는 상황에서 놓치기 쉽습니다.
type Route = {
path: string;
auth: "public" | "private";
};
const ROUTES = {
home: { path: "/", auth: "public" },
// admin: { path: "/admin", autn: "private" }, // 오타
} satisfies Record<string, Route>;
ROUTES 내부 값이 Route를 만족해야 하므로 오타가 즉시 에러로 잡힙니다.
2) 배열 원소 타입을 검증하면서 리터럴을 유지
API 권한 스코프 목록 같은 것을 배열로 관리할 때:
type Scope = "read" | "write" | "admin";
const SCOPES = ["read", "write"] satisfies Scope[];
// 타입은 ("read" | "write")[] 로 추론되면서
// 동시에 원소가 Scope인지 검증됨
여기서 SCOPES의 원소 유니온을 뽑아 쓰고 싶다면:
type EnabledScope = (typeof SCOPES)[number];
// "read" | "write"
3) Record 키를 고정하고 값만 검증하기
키가 정해져 있고(예: 로케일), 값만 검증하고 싶을 때는 satisfies가 특히 깔끔합니다.
type Locale = "ko" | "en";
type Messages = {
greeting: string;
logout: string;
};
const I18N = {
ko: { greeting: "안녕하세요", logout: "로그아웃" },
en: { greeting: "Hello", logout: "Logout" },
} satisfies Record<Locale, Messages>;
type SupportedLocale = keyof typeof I18N;
// "ko" | "en"
만약 en을 빼먹거나 logout을 누락하면 컴파일 에러로 잡힙니다.
그래도 as const가 더 좋은 경우
1) “진짜 상수”로 취급되어야 하는 값
아래처럼 값이 변경되면 안 되고, 모든 속성이 readonly여야 안정적인 경우가 있습니다.
- 액션 타입 상수
- 프로토콜 상수
- 변하면 안 되는 매직 넘버/문자열 테이블
const ACTION = {
ADD: "ADD",
REMOVE: "REMOVE",
} as const;
type Action = (typeof ACTION)[keyof typeof ACTION];
2) 깊은 레벨까지 리터럴 고정이 필요할 때
as const는 중첩 객체까지 readonly로 고정하고 리터럴을 최대한 좁힙니다.
const CONFIG = {
feature: {
enabled: true,
mode: "safe",
},
} as const;
// CONFIG.feature.mode 는 "safe" 리터럴
satisfies는 “검증”이 목적이므로, 깊은 레벨의 리터럴 고정이 목표라면 as const가 더 직관적입니다.
실무에서 추천하는 선택 기준
1) “검증 + 추론 유지”가 목적이면 satisfies
- 객체 리터럴을
Record<string, ...>로 검증하고 싶은데 키 유니온을 잃기 싫다 - 값 유니온을 뽑아 쓰고 싶다
- 설정/맵/테이블에서 오타를 잡고 싶다
이때 satisfies가 가장 깔끔합니다.
2) “불변 상수”가 목적이면 as const
- 수정되면 안 되는 상수 테이블
- 깊은 레벨까지 리터럴과
readonly가 필요
3) 둘을 섞는 패턴도 가능
예를 들어 “불변이면서도 형태 검증”이 필요하면 다음처럼 조합할 수 있습니다.
type Route = {
path: string;
auth: "public" | "private";
};
const ROUTES = {
home: { path: "/", auth: "public" },
admin: { path: "/admin", auth: "private" },
} as const satisfies Record<string, Route>;
여기서 주의할 점은 as const로 인해 값이 readonly가 되므로, 이후에 ROUTES.admin.path = ... 같은 변경은 불가능합니다. 정말 상수로 다룰 때만 쓰는 것이 좋습니다.
자주 하는 실수와 디버깅 팁
실수 1: 타입 주석 : 로 추론을 스스로 죽임
const x: SomeType = ...는 “x를 SomeType으로 취급”합니다. 리터럴 추론이 필요할 때는 satisfies를 우선 고려하세요.
실수 2: satisfies를 썼는데도 타입이 넓어짐
대개는 목표 타입이 너무 넓게 정의되어 있거나, 이미 한 번 넓혀진 값을 다시 검증하고 있는 경우입니다. 예를 들어 중간 변수에 Record<string, string>을 붙여버리면 그 순간 리터럴이 깨졌고, 이후 satisfies로는 복구가 안 됩니다.
type M = Record<string, string>;
const tmp: M = { A: "a", B: "b" }; // 여기서 이미 string으로 넓어짐
const obj = tmp satisfies M;
// obj의 값 리터럴은 돌아오지 않음
해결은 “처음 객체 리터럴을 만드는 지점”에서 바로 satisfies를 적용하는 것입니다.
마무리
as const는 “리터럴 최대한 좁힘 + 깊은 불변”에 강하고satisfies는 “형태 검증 + 추론 유지”에 강합니다.
특히 TS 5.x에서 satisfies는 Record 기반 설정/맵에서 키 유니온과 값 리터럴을 살려주기 때문에, 타입 깨짐을 체감적으로 크게 줄여줍니다.
추론이 깨지는 지점이 : 타입 주석인지, Record<string, ...> 같은 넓은 타입 강제인지부터 확인하고, “검증만 필요”한 곳은 satisfies로 바꿔보면 대부분의 문제가 정리됩니다.