- Published on
TypeScript 5.x satisfies로 타입오류 조기 차단하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 오래 쓰다 보면 “타입은 맞는데 런타임에서 터지는” 순간을 반복해서 만나게 됩니다. 특히 설정 객체(config), 라우트/권한 매핑, 이벤트 핸들러 레지스트리처럼 큰 객체 리터럴을 한 번에 선언하는 영역에서 자주 발생합니다.
전통적으로는 as SomeType 단언으로 “일단 통과”시키곤 했는데, 이 방식은 검증(타입 체크)을 포기하는 것과 비슷합니다. TypeScript 5.x의 satisfies는 이 문제를 정면으로 해결합니다.
- 객체가 특정 타입을 만족(satisfy) 하는지 검증하면서
- 값의 구체적인 리터럴 타입(좁은 타입) 은 유지하고
- 결과적으로 자동완성/추론과 안전성을 동시에 얻습니다.
이번 글에서는 satisfies가 왜 필요한지, as/명시적 타입 주석과의 차이, 그리고 실무에서 타입오류를 조기에 잡는 대표 패턴을 코드로 정리합니다. (운영에서 “왜 갑자기 설정이 안 먹지?” 같은 장애를 줄이는 관점에서요. 비슷한 맥락의 실전 트러블슈팅 글로는 Next.js 14 RSC 캐시로 데이터가 안 바뀔 때도 함께 참고하면 좋습니다.)
satisfies란 무엇인가
expr satisfies T는 표현식 expr이 타입 T의 요구사항을 만족하는지 체크만 하고, 표현식 자체의 타입은 원래 추론된 타입을 유지합니다.
즉 아래 두 가지를 동시에 달성합니다.
- 검증:
expr이T에 할당 가능(assignable)해야 함 - 보존:
expr의 리터럴/구체 타입을 잃지 않음
as 단언, 타입 주석과의 차이
아래 예시를 봅시다.
type Env = "dev" | "prod";
// 1) 타입 주석: 객체 전체가 Env로 '넓혀질' 수 있음
const config1: { env: Env } = { env: "dev" };
// config1.env 타입은 Env
// 2) as 단언: 검증을 사실상 우회할 수 있음
const config2 = { env: "deev" } as { env: Env };
// 컴파일 통과(단언), 런타임에서나 문제
// 3) satisfies: 검증은 하되, 값의 구체 타입은 유지
const config3 = { env: "dev" } satisfies { env: Env };
// config3.env 타입은 "dev" (리터럴 유지)
핵심은 2)입니다. as는 “내가 맞다고 우길게”라서 오타/누락/초과 필드를 숨기기 쉽습니다. 반면 satisfies는 “이 스펙을 만족하는지 검사해줘”라서 오타를 컴파일러가 잡습니다.
언제 satisfies가 특히 강력한가
실무에서 satisfies가 빛나는 곳은 대체로 아래 4가지입니다.
- 설정 객체: 환경변수/피처 플래그/서드파티 키
- 매핑 테이블: 상태 → 메시지, 권한 → 라우트, 에러코드 → 핸들러
- 레지스트리 패턴: 이벤트명 → 핸들러 함수
- 스키마/메타데이터: 폼 필드 정의, 테이블 컬럼 정의
이런 값들은 “딱 한 번 선언”하고 “여러 곳에서 참조”되기 때문에, 초기에 타입이 무너지면 전체가 흔들립니다. 운영에서 장애가 커지는 패턴이기도 하죠(예: 잘못된 설정으로 요청이 실패하거나, 특정 라우트만 권한 체크가 누락되는 등). 운영 문제를 체크리스트로 막는 방식에 관심이 있다면 Aurora PostgreSQL remaining connection slots are reserved… 체크리스트 같은 글의 접근법도 유사합니다.
패턴 1) “키 누락/초과”를 매핑에서 잡기
가장 흔한 예: 유니온 타입의 모든 케이스를 매핑으로 다루고 싶을 때입니다.
type Role = "guest" | "member" | "admin";
const roleLabel = {
guest: "게스트",
member: "회원",
admin: "관리자",
} satisfies Record<Role, string>;
// roleLabel은 아래처럼 안전하게 사용 가능
function getRoleLabel(role: Role) {
return roleLabel[role];
}
여기서 satisfies의 장점은 두 가지입니다.
Record<Role, string>을 만족해야 하므로 키 누락이 있으면 에러- 동시에
roleLabel.guest는 실제 값 리터럴("게스트")로 추론될 수 있어(상황에 따라) 더 정밀한 타입 정보를 유지
누락을 실제로 잡는 예
type Role = "guest" | "member" | "admin";
const roleLabel = {
guest: "게스트",
member: "회원",
// admin 누락!
} satisfies Record<Role, string>;
// ^ 컴파일 에러: Property 'admin' is missing
초과 키도 잡을 수 있나?
객체 리터럴은 “초과 프로퍼티 검사(excess property check)”가 걸리지만, Record<Role, string> 자체는 인덱스 시그니처 성격이라 초과 키가 논쟁적일 수 있습니다. 초과 키까지 엄격히 막고 싶다면, 다음처럼 “정확한 키 집합”을 강제하는 타입을 별도로 만들기도 합니다.
type ExactRecord<K extends PropertyKey, V> =
Record<K, V> & { [P in Exclude<string, K>]?: never };
type Role = "guest" | "member" | "admin";
const roleLabel = {
guest: "게스트",
member: "회원",
admin: "관리자",
// superadmin: "슈퍼" // <- 켜면 에러 유도 가능
} satisfies ExactRecord<Role, string>;
프로젝트 성격에 따라 “초과 키는 허용(미래 확장)” vs “초과 키는 금지(오타 방지)”를 선택하면 됩니다.
패턴 2) 라우트 정의에서 파라미터/메서드 오류 잡기
라우트 테이블을 객체로 관리하는 경우, satisfies는 오타를 매우 초기에 잡아줍니다.
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type RouteSpec = {
method: HttpMethod;
path: `/${string}`;
auth: "public" | "user" | "admin";
};
const routes = {
home: { method: "GET", path: "/", auth: "public" },
login: { method: "POST", path: "/login", auth: "public" },
adminUsers: { method: "GET", path: "/admin/users", auth: "admin" },
// 오타 예시: method "GEET"는 불가
// broken: { method: "GEET", path: "/x", auth: "public" },
} satisfies Record<string, RouteSpec>;
// routes.home.method는 "GET" 리터럴로 유지될 수 있음
여기서 routes: Record<string, RouteSpec>처럼 타입 주석을 달아버리면, 각 항목이 RouteSpec으로 넓혀져서 리터럴 정보가 손실될 수 있습니다. satisfies는 검증만 하고 정보는 남겨 이후 파생 타입을 만들 때 유리합니다.
패턴 3) 이벤트 핸들러 레지스트리에서 시그니처 강제
이벤트 이름과 페이로드 타입을 묶어두고, 핸들러 맵이 이를 만족하는지 확인하는 패턴입니다.
type EventMap = {
"user.created": { id: string; email: string };
"user.deleted": { id: string };
};
type Handler<E> = (payload: E) => Promise<void> | void;
type HandlerMap = { [K in keyof EventMap]: Handler<EventMap[K]> };
const handlers = {
"user.created": async (payload) => {
// payload.email 자동완성 OK
console.log(payload.email);
},
"user.deleted": (payload) => {
console.log(payload.id);
},
// "user.deleted": (payload) => console.log(payload.email) // <- email 없어서 에러
} satisfies HandlerMap;
이 패턴은 메시지 큐/웹훅/도메인 이벤트 기반 코드에서 특히 효과가 큽니다. 핸들러를 추가/수정할 때마다 “페이로드 필드명 오타” 같은 실수를 컴파일 타임에 잡습니다.
패턴 4) 설정 객체에서 as를 제거하고 안전하게 좁은 타입 유지
환경별 설정을 만들 때 as const와 섞어 쓰면 더 강력합니다.
type AppConfig = {
env: "dev" | "prod";
apiBaseUrl: string;
featureFlags: {
newCheckout: boolean;
auditLog: boolean;
};
};
const config = {
env: "prod",
apiBaseUrl: "https://api.example.com",
featureFlags: {
newCheckout: true,
auditLog: false,
},
// typo: apiBaseURL: "..." // <- 켜면 누락/오타를 잡을 수 있음
} satisfies AppConfig;
// config.env는 "prod"로 유지될 수 있어
// if (config.env === "prod") 같은 분기에서 추론이 더 명확해짐
satisfies + as const는 언제 쓰나
as const는 값을 깊게(readonly + 리터럴) 고정satisfies는 스펙을 만족하는지 검증
예를 들어 “상태 머신”이나 “UI 카피”처럼 값이 바뀌면 안 되는 테이블은 다음처럼 씁니다.
type Status = "idle" | "loading" | "success" | "error";
const copy = {
idle: "대기 중",
loading: "불러오는 중",
success: "완료",
error: "오류",
} as const satisfies Record<Status, string>;
// copy.success 타입은 "완료" (리터럴)
이 조합은 “값은 상수로 고정하고, 키는 유니온을 완전하게 커버”하는 데 최적입니다.
흔한 오해와 주의점
1) satisfies는 타입 변환이 아니다
satisfies는 결과 타입을 T로 바꾸지 않습니다. 어디까지나 “검증 연산자”입니다.
type Shape = { kind: "circle" | "square" };
const x = { kind: "circle", radius: 10 } satisfies Shape;
// x의 타입은 { kind: "circle"; radius: number }
// Shape로 '변환'된 게 아님
그래서 x를 Shape가 필요한 곳에 넘기면(구조적 타이핑이라) 대개 문제 없지만, “정확히 Shape 타입으로 만들고 싶다”면 별도의 타입 주석/함수 경계를 활용하세요.
2) 함수 반환값에는 직접 못 붙인다(표현식에는 가능)
satisfies는 표현식에 붙일 수 있으니, 함수 반환 객체에도 적용하려면 중간 변수를 두는 식으로 씁니다.
type Spec = { port: number; host: string };
function makeConfig() {
const cfg = { port: 3000, host: "localhost" } satisfies Spec;
return cfg;
}
3) “검증 위치”를 경계에 둬라
가장 좋은 위치는 보통:
- config/route/event-map 같은 정적 선언 지점
- 외부 입력(JSON)을 파싱한 뒤 검증/정규화한 결과를 담는 지점
외부 입력은 satisfies만으로는 런타임 안전을 보장하지 못합니다(타입은 컴파일 타임이므로). 이 경우 Zod/Valibot 같은 런타임 스키마 검증과 함께 쓰는 게 정석입니다.
실무 적용 체크리스트
as SomeType로 큰 객체를 덮고 있다면 →satisfies SomeType로 바꿀 수 있는지 먼저 검토- 유니온 타입의 모든 케이스를 다뤄야 하는 매핑이라면 →
satisfies Record<Union, ...>로 누락 방지 - 리터럴 타입을 유지해야 파생 타입이 쉬운 곳(라우트/카피/메타데이터)이라면 → 타입 주석 대신
satisfies우선 - 초과 키(오타)까지 막고 싶다면 →
ExactRecord같은 보조 타입을 도입
설정/매핑 오류는 배포 후에야 드러나면 비용이 큽니다. 배포 파이프라인에서 권한/인증 설정이 꼬여 장애로 이어지는 경우도 많고요. 배포 단계에서의 안전장치라는 관점에서는 GitHub Actions OIDC로 AWS 배포 AccessDenied 해결처럼 “원인을 조기에 드러내는” 접근이 중요합니다. satisfies는 코드 레벨에서 그 역할을 해주는 도구입니다.
결론
TypeScript 5.x의 satisfies는 “타입을 맞춘다”기보다 “스펙을 만족하는지 검증한다”에 가깝습니다.
as로 숨겨지던 오타/누락을 컴파일 타임에 잡고- 타입 주석으로 잃기 쉬운 리터럴 정보를 보존해
- 설정/매핑/레지스트리 같은 사고가 잦은 지점의 안정성을 크게 올립니다.
코드베이스에서 as 단언이 많고, 객체 리터럴 기반의 테이블이 많다면 satisfies로 바꾸는 것만으로도 “배포 후 발견되는 타입성 버그”를 눈에 띄게 줄일 수 있습니다.