- Published on
TS 5.x satisfies로 타입 깨짐 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 코드에서 설정 객체, 라우트 테이블, 권한 맵, 이벤트 핸들러 레지스트리처럼 “큰 객체 리터럴”을 자주 만듭니다. 이때 흔히 겪는 문제가 두 가지입니다.
- 타입을 강하게 걸면 추론이 죽고(리터럴이 넓어지고)
- 추론을 살리면 검증이 약해져서 깨진 타입이 런타임까지 숨어 들어갑니다.
TypeScript 5.x의 satisfies는 이 딜레마를 꽤 깔끔하게 해결합니다. 핵심은 “검증은 하되, 타입을 강제 캐스팅하지 않는다” 입니다.
아래에서 as, 타입 어노테이션, satisfies의 차이를 비교하고, 실무에서 타입 깨짐을 조기에 잡는 패턴을 예제로 정리합니다.
as / 타입 어노테이션이 만드는 함정
1) as SomeType는 검증이 아니라 “우기기”다
as는 타입 시스템에 “이 값은 이 타입이 맞다”고 강제로 믿게 합니다. 그래서 객체 리터럴이 틀려도 컴파일이 통과할 수 있습니다.
type User = {
id: string;
role: "admin" | "user";
};
// 실수: id를 number로 넣었지만 as로 덮어버림
const u = { id: 123, role: "admin" } as User;
// 컴파일 통과, 런타임에서 문제로 이어질 수 있음
2) 타입 어노테이션은 검증은 되지만 추론이 넓어진다
객체에 타입을 직접 붙이면 초과 속성 검사(excess property check)는 잘 되지만, 리터럴 타입이 종종 넓어져서 이후 로직이 불편해집니다.
type Routes = Record<string, { method: "GET" | "POST"; path: string }>;
const routes: Routes = {
health: { method: "GET", path: "/health" },
// ...
};
// routes.health.method 의 타입은 "GET" | "POST" 로 넓어짐
여기서 routes.health.method가 실제로는 항상 "GET"인데도 타입이 넓어지면, 분기 최적화나 매핑 타입에서 “정확한 리터럴”을 활용하기가 어려워집니다.
satisfies의 핵심: 검증 + 추론 유지
expr satisfies T는 다음을 동시에 만족합니다.
expr가T를 만족하는지 검사한다 (틀리면 컴파일 에러)- 하지만
expr자체의 타입은 원래 추론된 타입을 유지한다
즉, “T로 캐스팅”하지 않고 “T를 만족하는지 검증”만 합니다.
type Routes = Record<string, { method: "GET" | "POST"; path: string }>;
const routes = {
health: { method: "GET", path: "/health" },
login: { method: "POST", path: "/login" },
} satisfies Routes;
// routes.health.method 는 "GET" 으로 유지(리터럴 유지)
이 차이가 실무에서 꽤 큽니다. 라우트, 이벤트, 권한 같은 “정적 테이블”을 선언할 때 satisfies는 타입 깨짐을 잡으면서도, 테이블을 기반으로 한 파생 타입을 더 정확하게 만들 수 있습니다.
패턴 1: 설정 객체에서 오타·누락을 조기에 잡기
환경별 설정이나 기능 플래그는 보통 객체 리터럴로 관리합니다. 여기서 satisfies는 다음을 잡는 데 강합니다.
- 필수 키 누락
- 값 타입 오류
- (상황에 따라) 불필요한 키 추가
type AppConfig = {
env: "dev" | "prod";
apiBaseUrl: string;
retry: {
maxAttempts: number;
backoffMs: number;
};
};
const config = {
env: "prod",
apiBaseUrl: "https://api.example.com",
retry: {
maxAttempts: 5,
backoffMs: 200,
},
// typo: apiBaseURL 같은 오타 키를 넣으면 잡고 싶다
} satisfies AppConfig;
여기서 중요한 포인트는 config.env가 "prod"로 유지되는 등 리터럴 추론이 살아있다는 점입니다. 이후 if (config.env === "prod") 같은 분기에서 더 정밀한 타입 흐름을 얻을 때 도움이 됩니다.
패턴 2: 권한/역할 매트릭스에서 “키 누락”을 강제하기
권한 매트릭스는 보통 “역할별 허용 작업”처럼 생깁니다. 이때 자주 깨지는 타입은 역할 키 누락입니다.
type Role = "admin" | "manager" | "viewer";
type PermissionMatrix = Record<Role, {
canRead: boolean;
canWrite: boolean;
canDelete: boolean;
}>;
const permissions = {
admin: { canRead: true, canWrite: true, canDelete: true },
manager: { canRead: true, canWrite: true, canDelete: false },
viewer: { canRead: true, canWrite: false, canDelete: false },
} satisfies PermissionMatrix;
만약 viewer를 빼먹으면 바로 컴파일 에러가 납니다. 반대로 as PermissionMatrix로 덮으면 누락이 숨어버릴 수 있습니다.
파생 타입 만들기: 리터럴 유지의 장점
permissions가 리터럴을 유지하므로, 아래처럼 “삭제 가능한 역할만 뽑기” 같은 타입 연산이 더 정확해집니다.
type RolesThatCanDelete = {
[R in keyof typeof permissions]: typeof permissions[R]["canDelete"] extends true ? R : never
}[keyof typeof permissions];
// RolesThatCanDelete 는 "admin" 으로 좁혀짐
패턴 3: 라우트 테이블에서 핸들러 시그니처 깨짐 잡기
Next.js나 Express 스타일로 라우트 핸들러를 맵으로 관리할 때, 핸들러의 입력/출력 타입이 살짝만 어긋나도 런타임 오류로 이어집니다.
아래는 “정해진 라우트 키”와 “핸들러 시그니처”를 강제하는 예입니다.
type RouteKey = "health" | "login";
type Handler<Req, Res> = (req: Req) = Promise<Res>;
type RouteSpec = {
health: Handler<{ traceId: string }, { ok: true }>;
login: Handler<{ username: string; password: string }, { token: string }>;
};
const handlers = {
health: async (req: { traceId: string }) = ({ ok: true as const }),
// 실수: password 누락
login: async (req: { username: string }) = ({ token: "t" }),
} satisfies RouteSpec;
위 코드는 login의 요청 타입이 스펙과 다르므로 컴파일 단계에서 바로 깨집니다.
여기서도 handlers 자체는 리터럴/함수 시그니처 추론을 유지하므로, keyof typeof handlers 같은 파생 타입이 깔끔합니다.
패턴 4: 이벤트/커맨드 레지스트리에서 철자 실수 방지
이벤트 이름을 문자열로 흩뿌리면 오타가 늘어납니다. satisfies는 “이 레지스트리가 특정 이벤트 집합을 모두 포함한다”를 강제하는 데 유용합니다.
type EventName = "USER_CREATED" | "USER_DELETED";
type EventPayloads = {
USER_CREATED: { id: string };
USER_DELETED: { id: string; reason?: string };
};
type EventHandlers = {
[E in EventName]: (payload: EventPayloads[E]) = void;
};
const eventHandlers = {
USER_CREATED: (p: { id: string }) = {
// ...
},
USER_DELETED: (p: { id: string; reason?: string }) = {
// ...
},
} satisfies EventHandlers;
EventName에 이벤트를 추가했는데 eventHandlers에 핸들러를 추가하지 않으면 즉시 깨집니다. “새 기능 추가 시 누락 방지”에 특히 강합니다.
satisfies 사용 시 자주 묻는 포인트
1) 초과 속성 검사는 어떻게 되나
객체 리터럴에 대해 satisfies를 쓰면 기본적으로 “지정한 타입을 만족하는지”를 보므로, 타입이 허용하지 않는 키를 넣으면 에러가 납니다.
다만 대상 타입이 인덱스 시그니처(Record<string, ...>)처럼 “아무 키나 허용”하면 초과 속성 검사의 의미가 약해집니다. 이 경우에는 키 집합을 더 구체적으로 만들거나, as const와 함께 키를 고정하는 전략이 필요합니다.
2) as const와의 관계
as const: 값을 최대한 리터럴/readonly로 고정satisfies: 특정 타입을 만족하는지 검증
둘은 경쟁 관계가 아니라 조합 관계입니다.
type HttpMethod = "GET" | "POST";
type Endpoint = {
method: HttpMethod;
path: string;
};
const endpoints = {
health: { method: "GET", path: "/health" },
login: { method: "POST", path: "/login" },
} as const satisfies Record<string, Endpoint>;
이 조합은 “값은 리터럴로 꽉 잡고, 형태는 스펙을 만족하는지 검증”하는 전형적인 패턴입니다.
3) 언제 satisfies가 특히 효과적인가
- 설정/상수 테이블이 크고 자주 바뀐다
- 키 누락이 장애로 이어질 수 있다
- 객체에서 파생 타입을 만들고 싶다 (
keyof typeof, 매핑 타입 등) as캐스팅을 줄이고 싶다
운영에서 장애를 줄이는 관점에서는, 이런 “정적 테이블”의 타입 깨짐을 컴파일 단계에서 잡는 게 가장 비용 효율이 좋습니다. 장애 원인 추적이 어렵고 재현이 힘든 케이스일수록 사전에 걸러야 합니다. 비슷한 맥락으로 운영 이슈를 빠르게 진단하는 글로는 Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결, 인프라 레벨에서의 장애 추적은 K8s CrashLoopBackOff와 OOMKilled 원인 추적도 참고할 만합니다.
실전 체크리스트: 타입 깨짐을 줄이는 선언 스타일
1) “스펙 타입”과 “값”을 분리한다
- 스펙:
type RouteSpec = { ... } - 값:
const routes = { ... } satisfies RouteSpec
이렇게 하면 스펙 변경 시 깨지는 지점이 선명합니다.
2) as SomeType는 마지막 수단으로 둔다
외부 라이브러리 타입이 부정확하거나, 점진적 마이그레이션 중이라면 as가 필요할 수 있습니다. 하지만 내부 상수/테이블에는 되도록 satisfies를 우선 적용하세요.
3) 파생 타입을 적극적으로 만든다
typeof routes 기반으로 키/입력/출력 타입을 뽑아 쓰면 문자열 상수 오타가 줄고, 리팩터링이 쉬워집니다.
const routes = {
health: { method: "GET", path: "/health" },
login: { method: "POST", path: "/login" },
} satisfies Record<string, { method: "GET" | "POST"; path: string }>;
type RouteName = keyof typeof routes;
function buildUrl(name: RouteName) {
return routes[name].path;
}
마무리
TypeScript에서 타입이 깨지는 순간은 대개 “값은 맞는 것 같은데 타입이 미묘하게 어긋난” 상태로 시작합니다. 특히 설정/레지스트리/테이블 형태의 객체는 한 번 틀어지면 영향 범위가 넓습니다.
satisfies는 이런 객체 리터럴에 대해 **검증(안전)**과 **추론 유지(유연)**를 동시에 가져옵니다. 기존에
as로 덮어두고 불안했던 코드- 타입 어노테이션 때문에 리터럴 정보가 날아가던 코드
가 있다면, 해당 지점부터 satisfies로 바꿔보는 것만으로도 타입 깨짐을 상당히 줄일 수 있습니다.