- Published on
TS 5.x satisfies로 타입추론 깨짐 해결하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
TypeScript를 오래 쓰다 보면 “타입은 맞는데 추론이 죽어서” 생산성이 떨어지는 순간을 자주 만납니다. 대표적으로 설정 객체나 매핑 테이블을 만들 때 : SomeType 같은 타입 주석을 붙이는 순간, 값이 가진 구체적인 리터럴 정보가 넓은 타입으로 확장(widening)되면서 자동완성, 분기 좁히기, 키 추론 등이 급격히 나빠집니다.
TS 4.9부터 도입되고 TS 5.x에서 사실상 정착한 satisfies는 이 문제를 꽤 우아하게 해결합니다. 핵심은 “이 값이 어떤 타입 조건을 만족하는지 검사하되, 값 자체의 추론 타입은 바꾸지 않는다”는 점입니다. 즉, 타입 안전성(검증)과 구체적 추론(리터럴 보존)을 동시에 가져갈 수 있습니다.
이 글에서는 satisfies가 해결하는 “타입추론 깨짐”의 전형적인 패턴과, TS 5.x에서 실전적으로 적용하는 방법을 코드 중심으로 정리합니다.
타입추론이 깨지는 전형적인 패턴
문제 1: 타입 주석이 리터럴을 넓혀버림
설정 객체를 만들 때 흔히 이렇게 씁니다.
type Env = "dev" | "prod";
type AppConfig = {
env: Env;
apiBaseUrl: string;
retry: {
max: number;
backoffMs: number;
};
};
const config: AppConfig = {
env: "dev",
apiBaseUrl: "https://example.test",
retry: {
max: 3,
backoffMs: 200,
},
};
여기서 config.env는 Env로, config.retry.max는 number로 보입니다. 타입 자체는 맞지만, 값이 실제로는 "dev", 3 같은 구체값이라는 정보가 사라졌습니다. 이게 항상 나쁜 건 아니지만, 다음과 같은 경우 DX가 급격히 나빠집니다.
config.env가 실제로는"dev"일 때만 가능한 분기 최적화/좁히기- 매핑 테이블에서 키/값을 리터럴로 유지해야 하는 경우
as const를 쓰기엔 타입 검증이 부족한 경우
문제 2: as const만 쓰면 “검증”이 없다
리터럴을 보존하려고 as const를 붙이면 추론은 좋아집니다.
const config = {
env: "dev",
apiBaseUrl: "https://example.test",
retry: {
max: 3,
backoffMs: 200,
},
} as const;
하지만 이건 AppConfig를 만족하는지 보장하지 않습니다. 예를 들어 retry.max를 실수로 문자열로 써도, 별도의 타입 검사가 없다면 놓치기 쉽습니다(물론 사용하는 지점에서 터질 수 있지만, “정의 시점”에 잡히는 게 가장 좋습니다).
satisfies의 핵심: 검증은 하되 추론은 유지
value satisfies Type는 다음을 동시에 수행합니다.
- 컴파일 타임에
value가Type을 만족하는지 검사 - 그러나
value변수의 타입은Type으로 “고정”하지 않고, 값에서 추론된 타입을 유지
즉 : 타입 주석과 달리, 타입을 덮어씌우지 않습니다.
예제: 설정 객체에서 추론 보존
type Env = "dev" | "prod";
type AppConfig = {
env: Env;
apiBaseUrl: string;
retry: {
max: number;
backoffMs: number;
};
};
const config = {
env: "dev",
apiBaseUrl: "https://example.test",
retry: {
max: 3,
backoffMs: 200,
},
} satisfies AppConfig;
// 추론 관점
// config.env: "dev" (리터럴 유지)
// config.retry.max: 3 (리터럴 유지)
// 동시에 AppConfig 형태 검증도 통과해야 함
이제 config는 “구체적인 값”을 유지하면서도, AppConfig의 구조/타입 요구사항을 만족해야만 컴파일이 됩니다.
satisfies가 특히 강력한 실전 패턴
1) 매핑 테이블: 키/값 리터럴을 살린 채로 타입 검증
예를 들어 에러 코드를 메시지로 매핑한다고 합시다.
type ErrorCode = "E_AUTH" | "E_TIMEOUT" | "E_UNKNOWN";
type ErrorMessageMap = Record<ErrorCode, string>;
const errorMessages = {
E_AUTH: "로그인이 필요합니다.",
E_TIMEOUT: "요청 시간이 초과되었습니다.",
E_UNKNOWN: "알 수 없는 오류입니다.",
} satisfies ErrorMessageMap;
function getErrorMessage(code: ErrorCode) {
return errorMessages[code];
}
여기서 얻는 이점은 다음과 같습니다.
ErrorCode에 새 값이 추가되면 매핑 누락을 즉시 컴파일 에러로 감지errorMessages객체 자체는 리터럴 기반으로 유지되어, 다른 파생 타입을 만들기 쉬움
예를 들어 키 목록이 필요하면:
type ErrorMessageKey = keyof typeof errorMessages;
// "E_AUTH" | "E_TIMEOUT" | "E_UNKNOWN"
2) 유니온 기반 “정책 객체”에서 좁히기 개선
정책(Policy) 같은 객체를 만들 때, 타입 주석을 붙이면 kind가 넓어져서 좁히기가 불편해지는 경우가 있습니다.
type Policy =
| { kind: "fixed"; value: number }
| { kind: "percent"; value: number };
const discountPolicy = {
kind: "percent",
value: 10,
} satisfies Policy;
function applyDiscount(price: number) {
if (discountPolicy.kind === "fixed") {
// 여기서 discountPolicy.kind는 "percent"로 이미 결정되어 있어서
// 사실상 dead branch가 될 수 있고, 추론도 더 구체적
return price - discountPolicy.value;
}
return price * (1 - discountPolicy.value / 100);
}
물론 위 예시는 상수라서 극단적이지만, 라우트 정의/이벤트 정의처럼 “선언부에서 리터럴을 최대한 살리고 싶은” 케이스에서 satisfies가 특히 유용합니다.
3) 라우트/이벤트 정의: 자동완성과 안전성 동시 확보
예를 들어 이벤트 이름과 페이로드 타입을 같이 관리한다고 합시다.
type EventSpec = {
[eventName: string]: (payload: unknown) => void;
};
type AppEvents = {
"user:login": (payload: { userId: string }) => void;
"user:logout": (payload: { reason?: string }) => void;
};
const handlers = {
"user:login": (payload: { userId: string }) => {
console.log(payload.userId);
},
"user:logout": (payload: { reason?: string }) => {
console.log(payload.reason);
},
} satisfies AppEvents;
function emit<E extends keyof AppEvents>(
event: E,
payload: Parameters<AppEvents[E]>[0],
) {
handlers[event](payload);
}
emit("user:login", { userId: "u_1" });
// emit("user:login", { userId: 123 }); // 컴파일 에러
여기서 handlers: AppEvents = ...로 선언해도 되지 않냐는 질문이 나오는데, 그 방식은 종종 핸들러 구현에서 불필요하게 타입이 넓어지거나(특히 리터럴/튜플/readonly 추론), 후속 파생 타입 생성에서 손해를 봅니다. satisfies는 “정의는 자유롭게, 검증은 엄격하게” 가져가게 해줍니다.
satisfies와 as const의 관계: 같이 쓰면 더 강해짐
as const는 리터럴 및 readonly를 강제합니다. satisfies는 구조를 검증합니다. 둘은 목적이 다르므로 함께 쓰는 조합이 자주 등장합니다.
예를 들어 HTTP 상태 코드를 상수로 관리하면서, 값이 특정 범위 타입을 만족하길 원한다고 합시다.
type HttpStatus = 200 | 201 | 400 | 401 | 403 | 404 | 500;
type StatusMap = Record<string, HttpStatus>;
const STATUS = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL: 500,
} as const satisfies StatusMap;
type StatusKey = keyof typeof STATUS;
// "OK" | "CREATED" | ...
type StatusValue = (typeof STATUS)[StatusKey];
// 200 | 201 | 400 | 401 | 403 | 404 | 500
이 패턴의 장점:
as const로 키/값을 최대한 구체화satisfies로 “값이 HttpStatus 집합 안에 있는지”를 선언 시점에 강제
“타입추론 깨짐”을 어떻게 진단할까
: 타입 주석을 붙였더니 추론이 망가졌다면, 보통 다음 증상이 같이 옵니다.
keyof typeof obj가 기대보다 넓어짐(예:string으로 변함)- 값이 리터럴이 아니라
string/number로만 보임 obj.someKey접근 시 자동완성이 줄어듦- 제네릭 함수에 넘겼을 때 타입 인자가 의도치 않게 넓게 추론됨
이때 우선순위는 보통 다음과 같습니다.
- “타입을 덮어씌우는” 선언(
const x: T = ...)을 “검증만 하는” 선언(const x = ... satisfies T)로 바꿔보기 - 리터럴/튜플/readonly가 필요하면
as const를 추가하고, 그 위에satisfies로 검증하기 - 그래도 안 되면 타입 설계를 조정(인덱스 시그니처 남용, 너무 넓은
Record<string, ...>등)
satisfies 사용 시 주의점
1) satisfies는 타입을 “변환”하지 않는다
satisfies는 캐스팅이 아닙니다. 즉 런타임 변화도 없고, 타입을 억지로 맞추는 것도 아닙니다. 타입이 안 맞으면 그냥 에러가 납니다.
type Conf = { port: number };
const bad = {
port: "3000",
} satisfies Conf;
// 컴파일 에러: string은 number에 할당 불가
2) 너무 넓은 목표 타입을 주면 효과가 반감
예를 들어 아래는 검증이 거의 의미가 없습니다.
const x = {
a: 1,
b: 2,
} satisfies Record<string, number>;
키가 무엇이든 허용되므로 누락/오타를 잡기 어렵습니다. 이런 경우엔 가능한 한 구체적인 유니온 키나 정확한 객체 타입을 목표로 두는 게 좋습니다.
3) “초과 속성 검사” 기대치 조정
객체 리터럴을 어떤 타입에 할당할 때 발생하는 초과 속성 검사(excess property checks)는 문맥에 따라 체감이 다를 수 있습니다. satisfies는 “만족 여부”를 검사하지만, 목표 타입이 인덱스 시그니처를 포함하거나 너무 넓으면 초과 속성에 관대한 형태가 됩니다. 결론적으로 satisfies만 믿기보다 목표 타입을 잘 설계해야 합니다.
TS 5.x에서 추천하는 적용 전략
1) “선언부”에 satisfies를 우선 적용
- 설정 객체
- 라우트 정의
- 이벤트 스펙
- 에러 코드/메시지 테이블
- 권한/역할 매트릭스
이런 선언부는 “한 번 정의하고 여러 군데서 파생 타입을 만드는” 경우가 많습니다. 이때 추론 보존이 곧 DX로 직결됩니다.
2) 함수 인자 레벨에서는 신중하게
함수 인자에서 satisfies를 남발하면 가독성이 떨어질 수 있습니다. 보통은 선언부 상수에 적용하고, 함수 시그니처는 제네릭/오버로드로 정리하는 편이 유지보수에 유리합니다.
3) CI에서 타입체크를 빠르게 유지하기
satisfies를 도입하면 선언부 타입 검증이 촘촘해져서 타입체크가 더 많은 파일을 따라가게 될 수 있습니다. 대규모 모노레포에서는 CI 캐시/빌드 전략이 함께 중요해집니다. 타입체크가 느려져서 개발 경험이 흔들린다면 아래 글들도 같이 참고할 만합니다.
마무리
TS 5.x에서 satisfies는 “타입 안전성과 타입추론을 동시에 잡는” 가장 실용적인 도구 중 하나입니다. 기존에 const x: T = ... 패턴을 습관적으로 쓰고 있었다면, 특히 선언부 상수/테이블/스펙 정의에서 satisfies로 바꿔보는 것만으로도 자동완성과 파생 타입 설계가 눈에 띄게 좋아집니다.
정리하면 다음 한 줄로 귀결됩니다.
- 타입을 “강제”해서 추론을 죽이지 말고, 타입을 “검증”해서 추론을 살리자:
satisfies.
