- Published on
TS 5.4 satisfies로 타입 깨짐 잡는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/클라이언트 코드가 커질수록 “타입은 맞는 것 같은데 런타임에서 깨지는” 지점이 늘어납니다. 대표적으로 설정 객체(config), 라우팅 테이블, 이벤트 핸들러 맵처럼 객체 리터럴이 진실의 원천(source of truth) 이 되는 구조에서 문제가 자주 생깁니다.
TypeScript에서 이런 객체를 다루는 전통적인 방법은 두 가지였습니다.
as SomeType로 단언해서 통과시키기(위험: 잘못된 키/값도 그냥 넘어감)const x: SomeType = { ... }로 타입을 고정하기(장점: 검증됨, 단점: 추론이 뭉개짐)
TS 5.4에서도 여전히 유효한 해법이 바로 satisfies입니다. 핵심은 “타입 검증은 하되, 값의 구체적인 타입 추론은 유지” 한다는 점입니다. 이 글에서는 satisfies로 타입 깨짐을 잡는 패턴을 예제 중심으로 정리합니다.
참고: TS 5.5에서의 좁히기 이슈와
satisfies·in조합은 별도 글로 더 깊게 다뤘습니다. TypeScript 5.5 좁히기 깨짐 - satisfies·in 해결
satisfies가 해결하는 문제: “검증 vs 추론”의 트레이드오프
먼저 const x: SomeType = ... 방식의 문제를 보겠습니다.
type Env = "dev" | "prod";
type AppConfig = {
env: Env;
apiBaseUrl: string;
retry: {
maxAttempts: number;
backoffMs: number;
};
};
const config: AppConfig = {
env: "dev",
apiBaseUrl: "https://api.example.com",
retry: {
maxAttempts: 3,
backoffMs: 200,
},
};
이 코드는 검증 관점에서는 좋습니다. 그런데 config.env의 타입은 "dev" | "prod"로 넓어집니다. 즉, 실제 값이 "dev"로 고정되어 있어도, 이후 코드에서 "dev"에 기반한 더 정교한 분기나 매핑을 만들 때 이점이 줄어듭니다.
반대로 타입을 안 붙이면 추론은 좋아지지만 검증이 약해집니다.
const config = {
env: "dev",
apiBaseUrl: "https://api.example.com",
retry: {
maxAttempts: 3,
backoffMs: 200,
},
};
여기서는 config.env가 "dev"로 잘 추론되지만, apiBaseUrl을 빼먹거나 retry.backoffMs를 문자열로 넣어도(상황에 따라) 늦게 발견될 수 있습니다.
satisfies는 이 둘을 동시에 잡습니다.
기본 패턴: satisfies로 “형태만 검사”하고 값은 그대로 둔다
type Env = "dev" | "prod";
type AppConfig = {
env: Env;
apiBaseUrl: string;
retry: {
maxAttempts: number;
backoffMs: number;
};
};
const config = {
env: "dev",
apiBaseUrl: "https://api.example.com",
retry: {
maxAttempts: 3,
backoffMs: 200,
},
} satisfies AppConfig;
이제 얻는 효과는 다음과 같습니다.
- 객체가
AppConfig의 구조를 만족하는지 컴파일 타임에 검증 - 동시에
config.env는 여전히"dev"로 좁게 추론될 수 있음(리터럴 보존)
즉, satisfies는 “이 값이 이 타입을 만족해야 한다”는 제약 조건만 걸고, 변수 자체의 타입을 강제로 AppConfig로 덮어씌우지 않습니다.
타입 깨짐을 조기에 잡는 3가지 실전 시나리오
1) 설정 객체에서 오타/누락을 즉시 잡기
설정 객체는 보통 키가 많고, 리팩터링 중에 키 이름이 바뀌거나 옵션이 추가되면서 깨집니다.
type FeatureFlags = {
enableNewCheckout: boolean;
enableBetaBanner: boolean;
};
const flags = {
enableNewCheckout: true,
enableBetaBaner: false,
// ^ 오타: Banner
} satisfies FeatureFlags;
여기서 satisfies가 없으면 flags는 그냥 “어떤 객체”로 추론되어 오타가 조용히 지나갈 수 있습니다. satisfies FeatureFlags를 붙이면 정확히 필요한 키 집합을 강제할 수 있어, 설정이 커질수록 효과가 커집니다.
추가로, “여분 키를 허용하지 않게” 만들고 싶다면 타입을 더 엄격히 설계해야 합니다. 하지만 대부분의 설정 객체는 “정확히 이 키들이 있어야 한다”가 요구사항이므로 satisfies만으로도 실익이 큽니다.
2) 라우팅/핸들러 맵에서 반환 타입 깨짐 잡기
라우팅 테이블이나 핸들러 맵은 키-함수 형태로 많이 만듭니다.
type Route = "/" | "/health" | "/users";
type Handler = (req: { traceId: string }) => Promise<{ status: number }>;
type RouteTable = Record<Route, Handler>;
const routes = {
"/": async (req) => ({ status: 200 }),
"/health": async (req) => ({ status: 200 }),
"/users": async (req) => ({ status: "ok" }),
// ^ number여야 하는데 string
} satisfies RouteTable;
이 패턴의 장점은 두 가지입니다.
- 키 누락(예:
"/users"를 빼먹음)을 즉시 발견 - 각 핸들러의 입력/출력 타입이 깨지는 것을 즉시 발견
그리고 routes["/health"] 같은 접근에서 키가 리터럴로 유지되어, 이후에 키 기반으로 더 안전한 조합을 만들기 좋습니다.
3) 이벤트-페이로드 맵에서 “잘못된 페이로드” 방지
프론트엔드/백엔드 모두 이벤트 기반 구조에서 “이 이벤트는 이 페이로드를 가져야 한다”는 규칙이 중요합니다.
type Events = {
"user.created": { id: string; email: string };
"user.deleted": { id: string };
};
type EventName = keyof Events;
type EventHandlerMap = {
[K in EventName]: (payload: Events[K]) => void;
};
const handlers = {
"user.created": (p) => {
p.email.toLowerCase();
},
"user.deleted": (p) => {
console.log(p.email);
// ^ Property 'email' does not exist
},
} satisfies EventHandlerMap;
핵심은 EventHandlerMap이 키마다 payload 타입이 달라지는 매핑 타입이라는 점입니다. satisfies를 붙이면 “객체 리터럴의 형태”가 이 매핑을 만족하는지 검사하면서, 각 핸들러의 매개변수 타입이 정확히 유도됩니다.
as 단언을 대체하는 안전장치로 쓰기
as는 종종 “일단 통과시키고 나중에 보자”가 됩니다. 하지만 설정/테이블류 데이터는 나중에 보면 늦습니다.
나쁜 예:
type Limits = {
maxUploadMb: number;
maxUsers: number;
};
const limits = {
maxUploadMb: "50",
maxUsers: 1000,
} as Limits;
이 코드는 컴파일이 통과할 수 있고, 런타임에서 숫자 연산이 터질 수 있습니다.
좋은 예:
type Limits = {
maxUploadMb: number;
maxUsers: number;
};
const limits = {
maxUploadMb: "50",
maxUsers: 1000,
} satisfies Limits;
이제 타입 깨짐을 즉시 잡습니다. 그리고 값의 리터럴 추론은 유지됩니다.
satisfies를 쓸 때 자주 마주치는 함정 3가지
1) satisfies는 “타입을 바꾸지 않는다”
satisfies는 검증만 합니다. 그래서 아래처럼 “검증 후 AppConfig 타입으로 쓰고 싶다”면 별도의 타입 주석이 필요할 수 있습니다.
const config = {
env: "dev",
apiBaseUrl: "https://api.example.com",
retry: { maxAttempts: 3, backoffMs: 200 },
} satisfies AppConfig;
// config는 AppConfig로 '변환'된 게 아니라, 원래 추론 타입을 유지합니다.
대부분은 이게 장점이지만, 어떤 API가 정확히 AppConfig를 요구한다면 다음 중 하나를 선택합니다.
- API 시그니처를 더 유연하게(구조적 타이핑) 만들기
- 필요한 지점에서만 안전하게 캐스팅하기(가능하면 최소화)
2) 리터럴 보존을 원하면 as const와 조합을 고려
satisfies만으로도 많은 경우 리터럴이 잘 유지되지만, 배열/튜플/중첩 구조에서 “완전한 리터럴 고정”이 필요하면 as const를 같이 씁니다.
type Palette = {
primary: string;
shades: readonly ["50", "100", "200"];
};
const palette = {
primary: "blue",
shades: ["50", "100", "200"],
} as const satisfies Palette;
여기서 as const는 값 자체를 최대한 리터럴로 고정하고, satisfies는 그 값이 Palette 제약을 만족하는지 검사합니다.
3) 인덱스 시그니처/Record를 쓸 때 키 범위를 의식하기
Record<string, T>는 키가 너무 넓어서 실수를 숨길 수 있습니다. 가능하면 유니온 키를 만들고 Record<Union, T>로 좁히는 게 좋습니다.
type Job = "import" | "export";
type JobConfig = Record<Job, { concurrency: number }>;
const jobs = {
import: { concurrency: 2 },
export: { concurrency: 1 },
cleanup: { concurrency: 1 },
// ^ Job에 없는 키
} satisfies JobConfig;
이런 식으로 “허용된 키 집합”을 타입으로 만들면, 테이블이 커질수록 유지보수성이 급상승합니다.
TS 5.4 기준 추천 적용 포인트 체크리스트
아래 항목 중 하나라도 해당하면 satisfies를 우선 검토하는 게 좋습니다.
- 객체 리터럴이 사실상 스키마/계약 역할을 한다(설정, 라우트, 이벤트, 권한 맵)
as SomeType단언을 습관적으로 쓰고 있다const x: SomeType = ...때문에 리터럴 추론이 뭉개져서 분기/매핑이 불편하다- 키 오타/누락이 런타임 장애로 이어진 적이 있다
추가로 타입 시스템 전반의 좁히기/추론 문제가 함께 얽혀 있다면, TS 5.5에서의 실제 사례도 같이 보면 도움이 됩니다.
마무리: “검증은 강하게, 추론은 그대로”가 핵심
TS 5.4의 satisfies는 새로운 문법이라기보다, 대규모 코드베이스에서 자주 겪는 딜레마(검증을 강화하면 추론이 약해지고, 추론을 살리면 검증이 약해지는 문제)를 깔끔하게 풀어주는 도구입니다.
특히 설정/테이블/맵 형태의 코드에서 satisfies를 표준으로 정해두면,
- 타입 깨짐을 PR 단계에서 바로 잡고
- 런타임 장애 가능성을 줄이며
- 리터럴 기반의 정교한 타입 추론을 유지
할 수 있습니다.
다음 리팩터링에서 as를 발견하면, 그 자리에 satisfies를 먼저 대입해보는 것부터 시작해보세요.