- Published on
TS 5.6 satisfies로 타입 추론 망침 해결하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀이 같은 코드베이스를 만지다 보면, 타입을 “더 안전하게” 만들려고 satisfies를 도입했다가 오히려 타입 추론이 망가지는 상황을 종종 봅니다. 특히 TS 5.6 환경에서 satisfies를 추가했더니 제네릭 추론이 넓어지거나, 리터럴 타입이 사라지거나, 유니온 분기가 무력화되는 식으로 문제가 체감됩니다.
이 글은 satisfies의 목적을 다시 정리하고, “추론을 살리면서 검증만 추가”하는 패턴을 중심으로 TS 5.6에서 안전하게 쓰는 법을 다룹니다.
satisfies는 무엇을 보장하고, 무엇을 바꾸나
핵심만 말하면 satisfies는 “값의 타입을 바꾸지 않고” “해당 타입을 만족하는지 체크”하는 연산자입니다.
as SomeType: 값의 타입을SomeType으로 강제 캐스팅합니다. 추론이 바뀝니다.: SomeType(타입 주석): 변수 타입을SomeType으로 고정합니다. 추론이 바뀝니다.satisfies SomeType: 값은 원래 추론된 타입을 유지하되,SomeType을 만족하는지 검사합니다.
그런데 실무에서 “추론이 망가졌다”고 느끼는 이유는 보통 두 가지입니다.
satisfies자체가 아니라,satisfies를 붙이기 위해 타입을 넓게 잡아버려서(예:Record<string, ...>), 원래 얻던 리터럴/튜플/키 유니온 추론이 사라짐satisfies로 검증하려는 타입이 너무 일반적이어서(예: 인덱스 시그니처, 넓은 유니온), 이후 연산에서 타입이 넓게 퍼지는 것을 “satisfies탓”으로 착각
아래에서 자주 깨지는 패턴을 재현하고, 올바른 대안을 제시합니다.
재현 1: Record<string, ...>로 검증하다가 키 유니온 추론이 사라짐
다음은 흔한 설정 객체 패턴입니다.
type RouteConfig = {
method: "GET" | "POST";
path: string;
};
const routes = {
home: { method: "GET", path: "/" },
login: { method: "POST", path: "/login" },
} satisfies Record<string, RouteConfig>;
이 코드는 얼핏 좋아 보이지만, Record<string, RouteConfig>가 문제의 씨앗입니다.
- 검증 관점에서는 “각 값이
RouteConfig를 만족하는지”만 확인하면 되는데 - 타입을
string인덱스 기반으로 생각해버리면, 이후에keyof typeof routes가"home" | "login"이 아니라 더 넓게 취급되는 흐름이 생길 수 있습니다(특히 다른 유틸과 결합할 때).
해결: 키를 잃지 않는 satisfies 대상 타입을 만들기
키 유니온을 보존하려면 string 인덱스가 아니라, “해당 객체의 키 집합”을 유지하는 형태가 필요합니다.
가장 간단한 실전 패턴은 다음입니다.
type RoutesMap = {
home: RouteConfig;
login: RouteConfig;
};
const routes = {
home: { method: "GET", path: "/" },
login: { method: "POST", path: "/login" },
} satisfies RoutesMap;
type RouteName = keyof typeof routes; // "home" | "login"
하지만 키가 동적으로 늘어나는 환경에서는 매번 RoutesMap을 만들기 어렵습니다. 그럴 땐 “값만 검증하고 키는 그대로 두는” 제네릭 헬퍼가 효과적입니다.
type ValuesAre<TValue> = Record<PropertyKey, TValue>;
const defineRoutes = <T extends ValuesAre<RouteConfig>>(r: T) => r;
const routes = defineRoutes({
home: { method: "GET", path: "/" },
login: { method: "POST", path: "/login" },
});
type RouteName = keyof typeof routes; // "home" | "login"
여기서 포인트는 T를 그대로 반환해서 “추론된 키/리터럴”을 유지한다는 점입니다. 검증은 T extends ...에서 수행됩니다.
재현 2: 리터럴 타입이 사라져 분기 로직이 무력화됨
리터럴 타입이 유지되면 안전한 분기(예: switch)가 가능한데, 타입을 넓게 잡는 순간 리터럴이 string으로 퍼집니다.
type Event =
| { type: "click"; x: number; y: number }
| { type: "submit"; formId: string };
const e = {
type: "click",
x: 1,
y: 2,
} satisfies Event;
// e.type은 "click"이어야 안전한데,
// 이후 조합 코드에서 넓어졌다고 느끼는 상황이 생김
위 코드 자체는 satisfies가 타입을 바꾸지 않지만, 실무에서는 여기서 멈추지 않고 배열/맵/함수 인자로 넘기는 순간 “넓은 타입”으로 다시 해석되는 경우가 많습니다.
해결 1: as const와 satisfies를 함께 써서 리터럴 고정
const e = {
type: "click",
x: 1,
y: 2,
} as const satisfies Event;
// e.type: "click"
as const는 값의 리터럴/readonly 성질을 고정satisfies Event는 그 고정된 값이Event를 만족하는지 검증
둘의 역할이 다릅니다.
해결 2: 유니온을 만족시키되 “특정 멤버”로 좁히고 싶다면 satisfies 대신 Extract 고려
Event를 만족하는지 체크하는 것과, 실제로 click 이벤트로 취급하고 싶은 것은 다른 문제입니다.
type ClickEvent = Extract<Event, { type: "click" }>;
const e = {
type: "click",
x: 1,
y: 2,
} satisfies ClickEvent;
이렇게 하면 검증 대상이 명확해져서, 이후 로직도 더 단단해집니다.
재현 3: 함수 반환 객체에 satisfies를 붙여 제네릭 추론이 깨짐
다음처럼 “팩토리 함수”에서 반환 객체를 satisfies로 검증하려는 시도가 많습니다.
type Handler<TIn, TOut> = {
parse: (input: unknown) => TIn;
handle: (input: TIn) => Promise<TOut>;
};
function makeHandler<TIn, TOut>(h: Handler<TIn, TOut>) {
return h;
}
const h = makeHandler({
parse: (u) => String(u),
handle: async (s) => s.length,
} satisfies Handler<string, number>);
여기서는 크게 문제 없어 보이지만, 실무에서는 Handler가 더 복잡해지고, satisfies의 대상 타입을 “너무 일반적인 형태”로 잡아 제네릭 추론이 약해지는 케이스가 나옵니다.
해결: 검증은 인자에서, 반환은 T 그대로 두기
가장 안정적인 패턴은 “검증은 함수 시그니처에서 하고, 호출부는 아무 것도 안 붙이는 것”입니다.
function makeHandler<TIn, TOut>(h: Handler<TIn, TOut>) {
return h;
}
const h = makeHandler({
parse: (u) => String(u),
handle: async (s) => s.length,
});
// TIn = string, TOut = number로 자연 추론
satisfies는 호출부에서 “검증만 추가하고 싶을 때” 유용하지만, 이미 함수 제네릭이 충분히 검증을 제공한다면 중복입니다. 중복 검증을 위해 타입을 넓게 만들면 오히려 손해가 납니다.
실전 패턴: defineX 헬퍼로 “검증 + 추론 보존”을 동시에
설정/레지스트리/라우팅/권한 맵 같은 곳에서 satisfies를 가장 많이 씁니다. 이때 베스트 프랙티스는 defineX 헬퍼로 패턴을 고정하는 것입니다.
예: 플러그인 레지스트리
type Plugin = {
name: string;
setup: () => void;
};
type PluginMap = Record<PropertyKey, Plugin>;
const definePlugins = <T extends PluginMap>(p: T) => p;
const plugins = definePlugins({
auth: {
name: "auth",
setup: () => {},
},
metrics: {
name: "metrics",
setup: () => {},
},
});
type PluginName = keyof typeof plugins; // "auth" | "metrics"
여기서 “검증은 제네릭 바운드에서”, “추론(키 유니온)은 T에서” 가져옵니다. satisfies를 직접 붙이지 않아도 같은 효과를 얻는 셈입니다.
만약 satisfies를 꼭 쓰고 싶다면, 다음처럼 “검증 타입을 좁게” 유지하세요.
const plugins = {
auth: { name: "auth", setup: () => {} },
metrics: { name: "metrics", setup: () => {} },
} satisfies Record<"auth" | "metrics", Plugin>;
Record<string, Plugin>보다 훨씬 안전합니다.
TS 5.6에서 특히 조심할 포인트 체크리스트
1) satisfies Record<string, ...>는 마지막 수단
키 유니온을 잃거나, 이후 keyof/매핑 타입에서 기대한 정밀도가 떨어질 가능성이 큽니다. 가능하면 아래 순서로 고려하세요.
- 구체적인 키를 가진 타입(명시적 맵 타입)
defineX제네릭 헬퍼- 정말 불가피할 때만
Record<string, ...>
2) 리터럴이 중요하면 as const를 먼저 생각
특히 라우트 path, 이벤트 type, 액션 type 같은 디스크리미네이티드 유니온 키는 리터럴 유지가 곧 안전성입니다.
- 리터럴 유지:
as const - 구조 검증:
satisfies
3) satisfies는 “검증”이지 “타입 좁히기”가 아님
Event 유니온을 만족한다고 해서 그 값이 자동으로 특정 멤버로 좁혀지지는 않습니다. 좁히기는 Extract, 사용자 정의 타입 가드, 혹은 더 구체적인 타입으로 satisfies를 거는 방식이 필요합니다.
4) 함수 제네릭이 이미 검증한다면 satisfies를 덜어내기
제네릭 추론이 잘 되는 구간에 satisfies를 억지로 끼워 넣다가 타입을 넓게 만들면, 결과적으로 추론이 약해집니다.
디버깅 팁: “추론이 깨진 지점”을 빠르게 찾는 법
타입 추론이 망가졌다고 느낄 때는, 실제로는 “어딘가에서 타입이 넓어지는 순간”이 있습니다. 아래 방법이 빠릅니다.
- 중간 변수로 쪼개고
keyof, 인덱싱 결과 타입을 확인
const raw = {
home: { method: "GET", path: "/" },
login: { method: "POST", path: "/login" },
};
type RawKeys = keyof typeof raw; // 기대: "home" | "login"
const checked = raw satisfies Record<string, RouteConfig>;
// checked는 boolean 결과가 아니라 "표현식"이므로,
// 보통은 `const routes = raw satisfies ...` 형태로 쓰지 않음
- “검증 타입이 너무 넓은지”부터 의심
string인덱스any- 과도하게 일반화된 유니온
- 검증과 추론을 분리
- 검증:
defineX의 제네릭 바운드 - 추론:
T반환
마무리: satisfies는 강력하지만, 타입 설계가 더 중요
TS 5.6에서 satisfies는 여전히 “검증을 추가하면서 값의 타입은 유지”하는 좋은 도구입니다. 다만 실무에서 문제가 되는 지점은 보통 satisfies가 아니라, satisfies를 적용하기 위해 검증 타입을 지나치게 넓게 잡아버리는 설계입니다.
- 키 유니온이 중요하면
Record<string, ...>를 피하고defineX패턴을 쓰기 - 리터럴이 중요하면
as const와 조합하기 - 좁히기가 목적이면
Extract등 다른 도구를 쓰기
운영 환경에서 이런 “작은 타입 설계 차이”가 장애로 번지는 흐름은 의외로 자주 나옵니다. 예를 들어 재시도/백오프 정책 같은 설정 객체가 넓은 타입으로 퍼지면 잘못된 값이 런타임까지 들어가고, 결국 장애 대응 비용이 커집니다. 관련해서는 OpenAI API 429 Rate Limit 재시도·백오프 설계처럼 정책을 구조적으로 설계하는 글도 함께 참고하면 좋습니다.
또한 배포 스크립트에서 “실패를 실패로 처리하지 못하는” 류의 문제도 타입 설계와 유사하게, 작은 디테일이 큰 사고로 이어집니다. 쉘 파이프라인 안정성은 Bash set -e가 무시될 때 - pipefail·trap도 같이 보면 연결해서 이해하기 좋습니다.
마지막으로, 타입이 넓어져 잘못된 설정이 들어가면 결국 런타임에서 터지고, 컨테이너 재시작 같은 형태로 관측되기도 합니다. 운영 트러블슈팅 관점은 EKS Pod 1분마다 재시작? livenessProbe 실패 해결도 도움이 됩니다.