- Published on
TS 5.x satisfies vs as const 타입추론 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 비슷해 보이지만 satisfies와 as const는 의도와 결과가 꽤 다릅니다. 둘 다 “타입을 더 정확하게 만들고 싶다”는 요구에서 출발하지만, 한쪽은 검증에 가깝고 다른 한쪽은 고정(리터럴/readonly화) 에 가깝습니다. TS 5.x에서 satisfies가 널리 쓰이기 시작하면서, 기존에 as const로 억지로 해결하던 패턴을 더 안전하고 읽기 좋게 바꿀 수 있게 됐습니다.
이 글은 다음을 목표로 합니다.
satisfies와as const의 핵심 차이를 “타입 추론 결과” 관점에서 이해- 설정 객체, 맵, 라우트, 이벤트 정의 등 실무에서 바로 쓰는 패턴 제시
- 둘을 섞어 쓰는 베스트 프랙티스와 흔한 함정 정리
참고로 Node 런타임/번들러 환경에서 TS 설정을 다루다 보면 모듈 시스템 이슈도 자주 만나는데, ESM 전환 관련해서는 Node.js 22에서 require가 깨질 때 ESM 전환도 같이 보면 좋습니다.
as const는 “값을 얼리고 타입을 좁힌다”
as const는 표현식을 가능한 한 좁은 타입으로 만들고, 객체/배열이면 readonly로 바꿉니다.
const roles = ["admin", "member", "guest"] as const;
// 타입: readonly ["admin", "member", "guest"]
type Role = (typeof roles)[number];
// "admin" | "member" | "guest"
객체에서도 동일합니다.
const config = {
env: "prod",
retry: 3,
features: {
newCheckout: true,
},
} as const;
// config.env 타입은 "prod"
// config.retry 타입은 3
// config.features.newCheckout 타입은 true
// 그리고 전부 readonly
as const의 장점
- 리터럴 유니온을 쉽게 만들 수 있음
- 키/값을 “정확히” 고정해 타입 레벨에서 재사용 가능
- 라우트 목록, 이벤트 이름 목록 같은 상수 정의에 매우 강력
as const의 단점(실무에서 자주 터짐)
- 숫자/불리언까지 리터럴로 고정되어 “너무 좁아짐”
readonly가 걸려서 이후 가공 로직에서 불편함- “이 값이 특정 타입을 만족하는지” 검증이 아니라 “그냥 단언(assert)”이라서, 잘못된 형태도
as로 밀어붙이면 통과시킬 수 있음
즉 as const는 “타입 안정성 검사”가 아니라 “타입을 이렇게 봐줘”에 가깝습니다.
satisfies는 “검증하되, 원래의 추론은 유지한다”
TS 4.9부터 도입된 satisfies는 TS 5.x에서 사실상 표준 패턴이 됐습니다. 핵심은 다음 한 줄입니다.
satisfies는 타입 체크를 수행하지만, 값의 추론 타입을 그 타입으로 강제하지 않는다
예시로 감을 잡아봅시다.
type AppConfig = {
env: "dev" | "stage" | "prod";
retry: number;
features: Record<string, boolean>;
};
const config = {
env: "prod",
retry: 3,
features: {
newCheckout: true,
},
} satisfies AppConfig;
여기서 중요한 포인트:
config는AppConfig를 만족하는지 검사됩니다.- 동시에
config.env는 여전히 리터럴로 추론될 수 있습니다(상황에 따라 다르지만, “강제로AppConfig로 캐스팅”하는 것보다 훨씬 자연스럽게 구체성이 유지됩니다). - 무엇보다
as AppConfig와 달리, 필드 누락/오타/타입 불일치가 있으면 제대로 에러를 냅니다.
as AppConfig와 satisfies AppConfig의 차이
type AppConfig = {
env: "dev" | "stage" | "prod";
retry: number;
};
// 1) 단언: 위험
const a = {
env: "prod",
retry: "3",
} as AppConfig;
// 컴파일러가 믿어버릴 수 있음(상황에 따라 경고 없이 넘어감)
// 2) 만족: 안전
const b = {
env: "prod",
retry: "3",
} satisfies AppConfig;
// 에러: retry는 number여야 함
as는 개발자가 “내가 책임질게”라고 선언하는 것이고, satisfies는 컴파일러에게 “이 규격을 만족하는지 검사해줘”라고 요청하는 것입니다.
실전 패턴 1: 키는 고정, 값은 유연한 맵 만들기
실무에서 제일 많이 나오는 케이스가 “키는 특정 집합이어야 하고, 값은 타입만 맞으면 된다”입니다.
예: 권한별 라벨 맵
type Role = "admin" | "member" | "guest";
type RoleLabelMap = Record<Role, string>;
const roleLabels = {
admin: "관리자",
member: "멤버",
guest: "게스트",
} satisfies RoleLabelMap;
// 오타나 누락이 있으면 즉시 에러
// 예: guets: "게스트" // 에러
// 예: guest 누락 // 에러
여기서 as const를 쓸 필요가 없습니다. 라벨 문자열을 리터럴로 고정할 이유가 없다면, satisfies가 더 적합합니다.
반대로 라벨을 리터럴로 “재사용”하고 싶다면
const roleLabels = {
admin: "관리자",
member: "멤버",
guest: "게스트",
} as const satisfies Record<"admin" | "member" | "guest", string>;
type AdminLabel = (typeof roleLabels)["admin"]; // "관리자"
이 패턴은 “형태 검증 + 값 리터럴 보존”을 동시에 가져갑니다.
실전 패턴 2: 라우트 정의에서 satisfies로 누락/오타 잡기
라우트 테이블을 상수로 정의할 때, as const만 쓰면 “형태 검증”이 약해질 수 있습니다.
type RouteDef = {
path: string;
auth: "public" | "user" | "admin";
};
type RouteKey = "home" | "login" | "admin";
type Routes = Record<RouteKey, RouteDef>;
const routes = {
home: { path: "/", auth: "public" },
login: { path: "/login", auth: "public" },
admin: { path: "/admin", auth: "admin" },
} satisfies Routes;
장점:
RouteKey에 정의된 키가 빠지거나 추가되면 에러auth값이 허용된 유니온이 아니면 에러path를 실수로 숫자로 넣는 등 타입 불일치 즉시 검출
라우트처럼 “배포 전에 CI에서 잡아야 하는 실수”는 satisfies가 특히 유용합니다. CI 최적화는 별개 주제지만, 타입 체크가 느려지는 팀이라면 GitHub Actions 매트릭스로 CI 시간 50% 줄이기처럼 파이프라인도 같이 손보면 체감이 큽니다.
실전 패턴 3: 이벤트 이름은 리터럴, 핸들러 시그니처는 검증
프론트/백엔드 모두에서 이벤트 기반 구조를 쓰면 “이벤트 이름-페이로드 타입 맵”을 자주 만듭니다.
type EventMap = {
"user.created": { id: string; email: string };
"user.deleted": { id: string };
};
type HandlerMap = {
[K in keyof EventMap]: (payload: EventMap[K]) => Promise<void>;
};
const handlers = {
"user.created": async (payload) => {
payload.email.toLowerCase();
},
"user.deleted": async (payload) => {
payload.id;
},
} satisfies HandlerMap;
여기서 얻는 효과:
- 키(
"user.created") 오타가 있으면 에러 - 핸들러 파라미터 타입이 자동 추론되며, 잘못된 필드 접근이 즉시 에러
handlers객체의 “추론 타입”은 과도하게 넓어지지 않고, 작성한 함수 시그니처도 자연스럽게 유지
as const로 이벤트 키를 고정하고 싶다면 보통 이벤트 이름 목록을 따로 뽑을 때입니다.
const eventNames = ["user.created", "user.deleted"] as const;
type EventName = (typeof eventNames)[number];
이렇게 “이름 목록”은 as const, “이름별 구현체 검증”은 satisfies로 역할을 분리하는 게 깔끔합니다.
실전 패턴 4: as const가 과하게 좁혀서 생기는 문제와 해결
as const를 설정 객체에 무심코 붙였다가, 숫자/불리언이 리터럴로 고정되어 곤란해지는 일이 흔합니다.
const retryPolicy = {
maxRetries: 3,
backoffMs: 200,
} as const;
function withRetry(maxRetries: number) {
return maxRetries;
}
withRetry(retryPolicy.maxRetries);
// 타입은 3이라서 number 자리에 들어가긴 하지만,
// 이후 연산/조합에서 "3"이라는 지나치게 좁은 타입이 전파될 수 있음
이런 경우는 satisfies가 더 자연스럽습니다.
type RetryPolicy = {
maxRetries: number;
backoffMs: number;
};
const retryPolicy = {
maxRetries: 3,
backoffMs: 200,
} satisfies RetryPolicy;
정리하면:
- “값을 리터럴로 박제할 이유가 없다”면
as const를 붙이지 않는 편이 낫습니다. - “형태만 검증하고, 값은 자연스럽게 쓰겠다”면
satisfies가 적합합니다.
실전 패턴 5: satisfies로 열거형 대체하기
TS에서 enum을 피하고 문자열 리터럴 유니온으로 대체하는 팀이 많습니다. 이때 satisfies가 좋은 접착제가 됩니다.
const Status = {
Pending: "pending",
Success: "success",
Failed: "failed",
} as const;
type Status = (typeof Status)[keyof typeof Status];
// "pending" | "success" | "failed"
여기에 “정해진 포맷을 만족해야 한다” 같은 제약을 걸고 싶다면 satisfies를 얹을 수 있습니다.
type LowercaseWord = `${string}`; // 예시: 실전에서는 더 구체적인 템플릿을 쓰기도 함
type StatusShape = Record<string, LowercaseWord>;
const Status = {
Pending: "pending",
Success: "success",
Failed: "failed",
} as const satisfies StatusShape;
포인트는 satisfies가 “검증 레이어”로 동작한다는 점입니다.
언제 무엇을 써야 하나: 선택 기준 체크리스트
as const를 우선 고려
- 배열/객체를 리터럴 유니온으로 뽑아내야 함
- 키/값 자체를 타입 레벨에서 재사용할 예정
- 변경 불가능(
readonly) 구조가 오히려 안전함
예: 라우트 키 목록, 이벤트 이름 목록, 국가 코드 목록, UI variant 목록
satisfies를 우선 고려
- 객체가 특정 인터페이스/레코드 형태를 “만족”해야 함
- 키 누락/오타를 컴파일 타임에 잡고 싶음
- 값 리터럴 고정이나
readonly가 불필요하거나 방해됨 as SomeType단언을 없애고 싶음
예: 설정 객체, 권한-라벨 맵, 이벤트 핸들러 맵, 의존성 주입 레지스트리
둘을 같이 쓰는 경우
- “값은 리터럴로 보존”하면서 “형태도 검증”하고 싶다
const permissions = {
admin: ["read", "write", "delete"],
member: ["read", "write"],
guest: ["read"],
} as const satisfies Record<string, readonly string[]>;
이 패턴은 특히 권한/피처 플래그처럼 “상수로 박아두되, 구조적 제약도 강하게” 가져가고 싶을 때 좋습니다.
흔한 함정 3가지
1) satisfies는 타입을 “바꾸지” 않는다
const x = value satisfies T는 x의 타입을 T로 만드는 문법이 아닙니다. “검사만” 합니다. 따라서 T로 고정된 타입이 필요하면 별도의 타입 주석이 필요할 수 있습니다.
type Config = { env: "dev" | "prod" };
const raw = { env: "prod" } satisfies Config;
// raw 타입은 작성한 값 기반으로 추론됨
const cfg: Config = raw;
// 여기서 Config로 고정 가능
2) as const는 깊게 readonly가 된다
객체 내부까지 readonly가 전파되므로, 이후에 값을 조립/수정하는 코드가 있으면 불편해집니다. 이때는 “상수 정의 레이어”와 “런타임 조립 레이어”를 분리하는 게 좋습니다.
3) as 단언으로 에러를 덮지 말 것
as는 정말 필요할 때만 쓰고, 대부분은 satisfies로 대체하는 편이 유지보수에 유리합니다. 특히 설정/인프라 값처럼 운영 장애로 이어질 수 있는 영역은 “검증”이 중요합니다.
마무리: TS 5.x에서의 권장 조합
- 상수 목록, 리터럴 유니온이 목적이면
as const - 객체가 어떤 스펙을 만족해야 한다면
satisfies - 둘 다 필요하면
as const satisfies SomeShape로 “리터럴 보존 + 구조 검증”
TS 5.x 코드베이스에서 as SomeType이 여기저기 보인다면, 그중 상당수는 satisfies로 바꾸는 것만으로도 타입 안정성이 올라가고(특히 오타/누락), 추론 품질도 좋아지는 경우가 많습니다. 한 번에 전부 바꾸기 어렵다면, 라우트/이벤트/권한 맵처럼 “정적 정의 객체”부터 적용해보는 걸 추천합니다.