- Published on
TS 5.x satisfies로 타입 좁힘 실패 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
TypeScript 5.x에서 satisfies를 도입하면 객체 리터럴이 특정 타입을 "만족"하는지 검증하면서도, 값의 더 구체적인 리터럴 타입을 최대한 유지할 수 있습니다. 그래서 많은 코드베이스가 as SomeType 캐스팅을 줄이기 위해 satisfies로 빠르게 이동했습니다.
그런데 실무에서 자주 부딪히는 함정이 있습니다. satisfies를 썼는데도 분기문에서 타입 좁힘이 기대대로 되지 않거나, 인덱싱/매핑 시점에 타입이 string이나 unknown처럼 넓게 남아 결국 또 캐스팅을 하게 되는 문제입니다.
이 글은 "왜 좁힘이 실패하는지"를 TS 타입 시스템 관점에서 설명하고, 실패 패턴별로 가장 유지보수 좋은 해결법을 제시합니다.
추가로 TS 5.5+에서 선언 추론이 더 엄격해지며 관련 이슈가 함께 터지는 경우가 있어, 필요하면 TS 5.5+ isolatedDeclarations 에러 실전 해결법도 같이 참고하면 좋습니다.
satisfies가 하는 일과 안 하는 일
핵심만 정리하면 다음과 같습니다.
expr satisfies T는expr이T에 할당 가능(assignable)한지 검사합니다.- 하지만
expr의 타입을T로 바꾸지 않습니다. - 따라서
satisfies는 "검증"이지 "주석(annotation)"이 아닙니다.
즉, 아래 코드는 obj의 타입을 HandlerMap으로 만들지 않습니다.
type HandlerMap = Record<string, (x: number) => string>;
const obj = {
a: (x: number) => `a:${x}`,
b: (x: number) => `b:${x}`,
} satisfies HandlerMap;
// obj의 타입은 여전히 { a: ..., b: ... } 기반으로 추론됨
이 특성 때문에, satisfies를 쓴 뒤에 "타입 좁힘이 자연스럽게 될 것"이라고 기대하면 어긋나는 지점이 생깁니다.
실패 패턴 1: 유니온 판별식(discriminant)이 넓게 추론됨
가장 흔한 케이스는 "판별식이 리터럴로 유지되지 않는" 상황입니다.
문제 예시
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string; hard: boolean };
const e = {
type: "deleted",
id: "1",
hard: true,
} satisfies Event;
if (e.type === "deleted") {
// 기대: e는 deleted 케이스로 좁혀져 hard 접근 가능
// 실제: 상황에 따라 e.hard에서 에러가 나거나,
// 다른 연산에서 좁힘이 깨지는 경우가 생김
console.log(e.hard);
}
겉보기엔 문제 없어 보이지만, 프로젝트 설정(특히 exactOptionalPropertyTypes, noUncheckedIndexedAccess)과 결합되거나, e를 다른 함수로 넘기는 순간 타입이 다시 넓어지며 좁힘이 약해지는 일이 발생합니다.
원인
satisfies는 "이 값이Event유니온 중 하나에 들어맞는다"만 확인합니다.- 하지만
e자체의 타입은 "객체 리터럴의 추론 결과"로 남습니다. - 이 추론 결과가 유니온과 정확히 같은 형태가 아닐 수 있고, 이후 흐름에서
Event로 다시 취급되는 과정에서 정보 손실이 생깁니다.
해결 1: as const로 판별식을 리터럴로 고정
const e = {
type: "deleted",
id: "1",
hard: true,
} as const satisfies Event;
if (e.type === "deleted") {
console.log(e.hard);
}
as const는 리터럴 유지에 강력합니다.- 단, 전체가
readonly가 되므로 이후 변경이 필요한 값에는 부적합합니다.
해결 2: const 제네릭 팩토리로 "리터럴 유지 + 타입 검증" 동시 달성
변경 가능해야 한다면 팩토리 패턴이 깔끔합니다.
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string; hard: boolean };
function defineEvent<const T extends Event>(e: T) {
return e;
}
const e = defineEvent({
type: "deleted",
id: "1",
hard: true,
});
if (e.type === "deleted") {
console.log(e.hard);
}
const제네릭은 리터럴을 최대한 유지합니다.satisfies없이도 "검증" 역할을 합니다.- 특히 이벤트/액션/메시지 스키마 정의에 가장 실용적인 패턴입니다.
실패 패턴 2: Record<string, ...>로 만족시키면 키가 string으로 붕괴
Record<string, T>는 구조적으로 매우 넓습니다. 이걸 satisfies로 만족시키면, 객체 리터럴의 키 집합을 잃어버리는 것처럼 느끼는 문제가 생깁니다.
문제 예시
type Handlers = Record<string, (payload: unknown) => void>;
const handlers = {
login: (p: { userId: string }) => {
console.log(p.userId);
},
logout: (_p: {}) => {},
} satisfies Handlers;
function call(name: string, payload: unknown) {
// name이 string이라면 핸들러 존재 여부가 불명확
// handlers[name]는 (payload: unknown) => void | undefined 같은 형태로 넓어짐
handlers[name](payload);
}
여기서 좁힘이 실패하는 본질은 satisfies가 아니라, Record<string, ...>와 string 인덱싱 조합이 "항상 있을 거"라는 보장을 못 한다는 점입니다.
해결 1: 키를 유니온으로 고정하고, 호출 API도 그 유니온을 사용
const handlers = {
login: (p: { userId: string }) => {
console.log(p.userId);
},
logout: (_p: {}) => {},
} satisfies Record<string, (payload: unknown) => void>;
type HandlerName = keyof typeof handlers;
function call(name: HandlerName, payload: unknown) {
handlers[name](payload);
}
call("login", { userId: "u1" });
이 방식의 포인트는 "키의 집합"을 keyof typeof handlers에서 얻고, 외부 API가 그 집합을 따르게 만드는 것입니다.
해결 2: 페이로드까지 타입 안전하게 만들기 (가장 추천)
핸들러별 payload 타입이 다르면, 아래처럼 "이벤트 이름으로 payload를 매핑"하는 설계가 좋습니다.
type PayloadByName = {
login: { userId: string };
logout: {};
};
type HandlerMap = {
[K in keyof PayloadByName]: (payload: PayloadByName[K]) => void;
};
const handlers = {
login: (p) => {
console.log(p.userId);
},
logout: (_p) => {},
} satisfies HandlerMap;
function call<K extends keyof HandlerMap>(name: K, payload: PayloadByName[K]) {
handlers[name](payload);
}
call("login", { userId: "u1" });
// call("login", {}); // 컴파일 에러
satisfies는 여기서 "구현이 스펙을 만족"하는지 검증합니다.- 좁힘은
K제네릭과 인덱스 접근 타입이 담당합니다.
실패 패턴 3: satisfies 뒤에 변수로 빼는 순간 리터럴이 넓어짐
객체를 조립하는 과정에서 중간 변수를 let으로 두거나, 리터럴이 아닌 표현식이 끼면 리터럴 타입이 string 등으로 넓어질 수 있습니다.
문제 예시
type Mode = "dev" | "prod";
declare const env: string;
const cfg = {
mode: env,
} satisfies { mode: Mode };
// env가 string이므로 cfg.mode는 string이고,
// 이후 분기에서 "dev" | "prod" 좁힘을 기대할 수 없음
해결 1: 런타임 가드로 먼저 좁히고, 그 결과를 사용
type Mode = "dev" | "prod";
function isMode(x: string): x is Mode {
return x === "dev" || x === "prod";
}
declare const env: string;
const mode: Mode = isMode(env) ? env : "prod";
const cfg = {
mode,
} satisfies { mode: Mode };
satisfies는 검증용으로만 두고,- 실제 좁힘은 타입 가드에서 책임지게 하면 예측 가능성이 높아집니다.
해결 2: zod 같은 스키마 검증 라이브러리와 결합
프로덕션에서는 문자열 환경변수처럼 "외부 입력"이 많습니다. 이때는 타입 가드 수동 작성보다 스키마 검증이 더 안전합니다.
import { z } from "zod";
type Mode = "dev" | "prod";
const ModeSchema = z.union([z.literal("dev"), z.literal("prod")]);
declare const env: string;
const mode = ModeSchema.catch("prod").parse(env) satisfies Mode;
const cfg = { mode } satisfies { mode: Mode };
주의: 위처럼 satisfies를 값에 직접 붙이면 가독성이 떨어질 수 있으니, 팀 컨벤션에 맞춰 정리하는 게 좋습니다.
실패 패턴 4: satisfies는 제어 흐름 기반 좁힘을 "강화"하지 않는다
종종 이런 기대를 합니다.
- "
satisfies로 타입을 지정했으니,if에서 더 잘 좁혀지겠지"
하지만 제어 흐름 기반 좁힘은 기본적으로 변수의 타입과 가드 조건으로만 결정됩니다. satisfies는 변수 타입을 바꾸지 않으므로, 좁힘 성능이 드라마틱하게 좋아지지 않습니다.
해결: 좁힘이 필요한 지점에선 satisfies 대신 "타입을 실제로 부여"하는 방식을 쓰기
예를 들어, API 응답을 내부 도메인 타입으로 다루고 싶다면 satisfies보다 다음이 명확합니다.
type User = { id: string; role: "admin" | "member" };
function assertUser(x: unknown): asserts x is User {
if (
typeof x !== "object" ||
x === null ||
!("id" in x) ||
!("role" in x)
) {
throw new Error("Invalid user");
}
}
const data: unknown = JSON.parse("{\"id\":\"1\",\"role\":\"admin\"}");
assertUser(data);
// 여기서부터 data는 User로 "실제로" 좁혀짐
if (data.role === "admin") {
// admin 분기
}
satisfies는 "정적" 검증 도구asserts/타입 가드는 "동적" 입력을 정적으로 변환하는 도구
둘의 역할을 분리하면 좁힘 실패로 인한 캐스팅이 줄어듭니다.
실전 레시피: satisfies를 써도 좁힘이 잘 되게 만드는 규칙
1) 객체 스펙 검증은 satisfies, 호출/사용은 keyof typeof와 제네릭
const routes = {
home: "/",
user: "/users/:id",
} satisfies Record<string, string>;
type RouteName = keyof typeof routes;
function href(name: RouteName) {
return routes[name];
}
satisfies는 "값이 문자열인지"만 확인- 타입 안전성은
RouteName로 확보
2) 판별 유니온은 const 제네릭 팩토리로 정의
type Action =
| { type: "add"; value: number }
| { type: "remove"; id: string };
function defineAction<const T extends Action>(a: T) {
return a;
}
const a = defineAction({ type: "add", value: 1 });
if (a.type === "add") {
a.value;
}
3) 외부 입력은 먼저 런타임 검증으로 좁히고, 그 다음 satisfies
type Config = { mode: "dev" | "prod"; port: number };
function parseConfig(x: unknown): Config {
if (typeof x !== "object" || x === null) throw new Error("bad");
const r = x as { mode?: unknown; port?: unknown };
const mode = r.mode === "dev" || r.mode === "prod" ? r.mode : "prod";
const port = typeof r.port === "number" ? r.port : 3000;
return { mode, port };
}
const cfg = parseConfig(JSON.parse("{}")) satisfies Config;
디버깅 팁: 왜 좁힘이 안 되는지 빠르게 확인하는 방법
- 변수에 마우스를 올려 "현재 타입"을 확인합니다.
- 여기서
string으로 넓어져 있으면, 분기문이 아무리 좋아도 좁힘이 제한됩니다.
- 중간 단계에 타입 별칭을 만들어
keyof typeof를 눈에 보이게 둡니다.
const handlers = {
login: (p: { userId: string }) => {},
logout: (_p: {}) => {},
} satisfies Record<string, (payload: unknown) => void>;
type Names = keyof typeof handlers;
// Names가 "login" | "logout"인지 확인
noUncheckedIndexedAccess가 켜져 있다면 인덱싱 결과에undefined가 섞이는 게 정상입니다.
- 이 경우는 좁힘 실패가 아니라 "존재성 체크가 필요"하다는 신호입니다.
결론: satisfies는 검증 도구, 좁힘은 설계로 해결
satisfies는 "타입을 바꾸지 않는다"는 점을 항상 염두에 둬야 합니다.- 타입 좁힘이 필요한 문제는 대개
- 키를
string으로 열어둔 설계, - 외부 입력을 정적으로 믿는 설계,
- 판별 유니온을 리터럴로 유지하지 못한 설계 에서 발생합니다.
- 키를
따라서 해결책도 satisfies 자체의 트릭보다,
keyof typeof로 키 집합을 고정하고- 제네릭으로 호출부를 연결하며
- 외부 입력은 런타임 검증으로 먼저 좁히는 방향이 가장 안정적입니다.
빌드/툴링 이슈까지 함께 겪고 있다면, TS 5.5+ 변화로 인해 선언 추론이 까다로워진 케이스도 많으니 TS 5.5+ isolatedDeclarations 에러 실전 해결법도 같이 점검해보면 문제 원인을 더 빨리 좁힐 수 있습니다.