- Published on
TS 5.6에서 satisfies로 타입 좁히기 실패 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 런타임 검증과 컴파일 타임 타입을 동시에 만족시키려다 보면 satisfies를 자주 쓰게 됩니다. 그런데 TS 5.6로 올리거나(혹은 기존 코드가 커지면서) 특정 지점에서 “분명 satisfies로 제약을 걸었는데 왜 타입이 안 좁혀지지?” 같은 상황을 만나곤 합니다.
핵심은 간단합니다. satisfies는 값의 타입을 바꾸지 않고(widening을 막거나 더 좁은 타입을 강제하지 않고) 해당 값이 특정 타입을 만족하는지만 검사합니다. 그래서 satisfies를 타입 가드처럼 기대하면 좁히기가 실패합니다.
이 글에서는 TS 5.6 기준으로 자주 터지는 실패 패턴을 재현하고, 각각을 안정적으로 고치는 실전 해법을 정리합니다. satisfies 자체의 장점은 살리되, “좁히기”는 다른 도구로 명확히 처리하는 방향입니다.
관련해서 satisfies를 활용한 전체 패턴은 아래 글도 함께 보면 맥락이 더 빨리 잡힙니다.
1) satisfies는 타입 가드가 아니다
먼저 문제를 가장 단순하게 재현해 보겠습니다.
type Animal =
| { kind: "cat"; meow: () => void }
| { kind: "dog"; bark: () => void };
const a = {
kind: "cat",
meow() {
console.log("meow");
},
} satisfies Animal;
// 기대: a.kind가 "cat"이니 cat으로 좁혀질 것 같다
// 현실: a는 여전히 "원래 추론된 타입"을 유지한다
여기서 중요한 포인트는 a의 타입이 Animal로 “선언”된 게 아니라는 점입니다. satisfies Animal은 다음을 의미합니다.
- 이 객체 리터럴이
Animal에 할당 가능한지 검사한다 - 하지만
a의 타입은 가능한 한 구체적으로 “그대로” 둔다
그래서 a는 얼핏 더 좋아 보이지만, 코드가 조금만 복잡해지면 원하는 방식으로 좁혀지지 않는 케이스가 생깁니다.
2) 실패 패턴 A: Record + 유니온 값에서 인덱싱하면 좁혀지지 않음
가장 흔한 케이스는 “맵(딕셔너리)에서 꺼내 쓰는 값”입니다.
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string; hard: boolean };
type Handler = (e: Event) => void;
const handlers = {
created: (e) => {
// e는 Event라서 여기서도 좁히기가 필요
if (e.type === "created") {
e.id;
}
},
deleted: (e) => {
if (e.type === "deleted") {
e.hard;
}
},
} satisfies Record<Event["type"], Handler>;
function dispatch(e: Event) {
// 문제: handlers[e.type]의 타입은 Handler로만 보이고
// e와의 상관관계(correlation)가 타입 시스템에 남지 않는다.
handlers[e.type](e);
}
여기서 개발자가 기대하는 건 “e.type이 created면 created 핸들러가, deleted면 deleted 핸들러가 호출되며 각 핸들러는 해당 이벤트만 받는다” 입니다.
하지만 Record<K, V>로는 키와 값의 상관관계가 표현되지 않습니다. satisfies는 단지 “이 객체가 Record를 만족한다”만 확인할 뿐, 키에 따라 값의 타입이 달라지는 매핑 관계를 유지해 주지 않습니다.
해결 1: 매핑 타입으로 상관관계를 타입에 새긴다
핵심은 “타입 레벨에서 type 값에 따라 이벤트 타입을 매핑”하는 것입니다.
type EventByType = {
created: Extract<Event, { type: "created" }>;
deleted: Extract<Event, { type: "deleted" }>;
};
type Handlers = {
[K in keyof EventByType]: (e: EventByType[K]) => void;
};
const handlers2 = {
created: (e) => {
// e는 created 이벤트로 고정
e.id;
},
deleted: (e) => {
// e는 deleted 이벤트로 고정
e.hard;
},
} satisfies Handlers;
function dispatch2<E extends Event>(e: E) {
// 여전히 인덱싱이 tricky할 수 있으니
// 아래처럼 제네릭을 type으로 제한해 주는 편이 안전하다.
const fn = handlers2[e.type as keyof EventByType];
fn(e as any);
}
위 예시는 “핸들러 정의 시점”에 타입 안정성을 크게 올려 줍니다. 다만 dispatch2에서 인덱싱과 제네릭을 깔끔하게 연결하려면 한 단계 더 정교한 설계가 필요합니다.
해결 2: 안전한 dispatch를 위한 헬퍼 함수(팩토리)로 캡슐화
호출부에서 as any 같은 우회를 쓰지 않으려면, 핸들러 등록을 팩토리로 감싸서 “상관관계”를 보존하는 방법이 실전에서 가장 깔끔합니다.
type EventByType2 = {
created: Extract<Event, { type: "created" }>;
deleted: Extract<Event, { type: "deleted" }>;
};
type HandlerMap = {
[K in keyof EventByType2]: (e: EventByType2[K]) => void;
};
function makeDispatcher<M extends HandlerMap>(map: M) {
return {
dispatch<K extends keyof M>(type: K, e: Parameters<M[K]>[0]) {
map[type](e);
},
};
}
const d = makeDispatcher({
created: (e) => e.id,
deleted: (e) => e.hard,
} satisfies HandlerMap);
d.dispatch("created", { type: "created", id: "1" });
// d.dispatch("created", { type: "deleted", id: "1", hard: true }); // 컴파일 에러
포인트는 dispatch의 인자가 type과 e로 분리되면서, K가 고정된 상태에서 Parameters<M[K]>[0]가 정확히 따라온다는 점입니다.
3) 실패 패턴 B: satisfies로 리터럴이 유지될 거라 믿었는데 widening 발생
satisfies는 “원래 추론된 타입”을 유지시키는 쪽에 가깝지만, 코드 구조에 따라 여전히 widening이 발생할 수 있습니다. 대표적으로 객체를 중간 변수에 담거나, 함수 반환값으로 넘기면서 리터럴이 넓어지는 경우입니다.
type Config = {
mode: "dev" | "prod";
retry: number;
};
function buildConfig() {
const cfg = {
mode: "dev",
retry: 3,
};
// 여기서 satisfies를 붙여도 cfg의 추론은 이미 끝난 상태라
// 기대한 만큼 리터럴이 보존되지 않는 흐름이 생길 수 있다.
return cfg satisfies Config;
}
해결: as const와 satisfies의 역할을 분리
- 리터럴 고정을 원하면
as const - 형태 검증을 원하면
satisfies
이 둘을 같이 쓰되, 목적을 섞지 않는 게 중요합니다.
type Config = {
mode: "dev" | "prod";
retry: number;
};
const cfg = {
mode: "dev",
retry: 3,
} as const satisfies Config;
// cfg.mode는 "dev"로 유지되면서도 Config 형태를 만족해야 한다.
주의할 점은 as const가 너무 강하면(예: 배열이 readonly 튜플이 됨) 이후 로직에서 수정이 어려워질 수 있다는 것입니다. “리터럴을 고정해야 하는 경계”에만 적용하세요.
4) 실패 패턴 C: satisfies로 분기 후 타입이 좁혀질 거라 착각
다음은 흔한 착각입니다. satisfies를 붙였으니 조건문에서 더 잘 좁혀질 거라고 기대하는 경우입니다.
type Ok = { ok: true; value: string };
type Err = { ok: false; error: string };
type Result = Ok | Err;
const r = {
ok: Math.random() > 0.5,
value: "v",
error: "e",
} satisfies Result;
if (r.ok) {
// 기대: Ok로 좁혀져서 r.value만 접근 가능
// 현실: r.ok가 boolean이면 좁혀지지 않는다.
r.value;
}
여기서 문제는 ok가 리터럴 true 또는 false가 아니라 그냥 boolean이라는 점입니다. satisfies Result는 “가능한 형태”를 검사할 뿐, ok를 판별자(discriminant)로 만들어주지 않습니다.
해결 1: 판별자를 리터럴로 만들기(생성 함수)
type Ok = { ok: true; value: string };
type Err = { ok: false; error: string };
type Result = Ok | Err;
function ok(value: string): Ok {
return { ok: true, value };
}
function err(error: string): Err {
return { ok: false, error };
}
const r2: Result = Math.random() > 0.5 ? ok("v") : err("e");
if (r2.ok) {
r2.value;
} else {
r2.error;
}
해결 2: 타입 가드로 좁히기(런타임 검사 포함)
type Result =
| { ok: true; value: string }
| { ok: false; error: string };
function isOk(r: Result): r is Extract<Result, { ok: true }> {
return r.ok === true;
}
declare const r3: Result;
if (isOk(r3)) {
r3.value;
} else {
r3.error;
}
정리하면, 좁히기는 satisfies가 아니라 타입 가드/판별자 설계로 해결해야 합니다.
5) TS 5.6에서 특히 헷갈리는 지점: “검증”과 “추론”의 경계
TS 5.6 자체가 satisfies의 의미를 바꾼다기보다, 프로젝트가 커질수록 다음 경계에서 문제가 표면화됩니다.
- 객체 리터럴을 즉시
satisfies로 검증하면 타입이 꽤 구체적으로 남는다 - 하지만 한 번 변수에 담고, 반환하고, 합성하고, 인덱싱하면
- 리터럴이 widening 되거나
- 키와 값의 상관관계가 끊기거나
- 유니온이 다시 뭉개져서 좁히기 포인트를 잃는다
따라서 TS 5.6에서 “갑자기 좁히기 실패”처럼 느껴지는 현상은 실제로는 다음 중 하나인 경우가 많습니다.
Record같은 단순 타입에 만족시키면서 상관관계를 잃음- 판별자 필드가 리터럴이 아니라 boolean, string 등으로 넓어짐
- 인덱싱(
obj[key])으로 유니온이 합쳐져 더 넓은 타입이 됨
6) 실전 체크리스트: satisfies로 좁히기 실패를 고치는 순서
1) 지금 원하는 게 “형태 검증”인지 “타입 좁히기”인지 분리
- 형태 검증:
satisfies가 정답 - 타입 좁히기: 타입 가드, 판별자 리터럴, 제네릭 상관관계가 정답
2) 딕셔너리라면 Record부터 의심
Record는 편하지만, “키에 따라 값 타입이 달라지는” 상황에서는 대부분 정보가 사라집니다. 매핑 타입과 Extract를 사용해 상관관계를 타입에 새기세요.
3) 판별자는 반드시 리터럴로
ok: boolean은 판별자가 아닙니다. ok: true | false가 아니라, 각 변형이 ok: true, ok: false로 고정되어야 좁혀집니다.
4) 호출부에서 인덱싱 좁히기를 기대하지 말고, API 형태를 바꿔라
handlers[e.type](e) 같은 형태는 타입 시스템이 표현하기 어렵습니다. dispatch(type, payload)처럼 제네릭이 붙을 수 있는 형태로 바꾸거나, 팩토리로 캡슐화하세요.
7) 예제: 이벤트 라우터를 TS 5.6 친화적으로 재구성
마지막으로, 실무에서 많이 쓰는 “이벤트 라우터”를 안전한 형태로 정리해 보겠습니다.
type Event =
| { type: "created"; id: string }
| { type: "deleted"; id: string; hard: boolean };
type EventByType = {
[K in Event["type"]]: Extract<Event, { type: K }>;
};
type HandlerMap = {
[K in keyof EventByType]: (e: EventByType[K]) => void;
};
function createRouter<M extends HandlerMap>(handlers: M) {
return {
emit<K extends keyof M>(type: K, e: Parameters<M[K]>[0]) {
handlers[type](e);
},
};
}
const router = createRouter({
created: (e) => {
e.id;
},
deleted: (e) => {
e.hard;
},
} satisfies HandlerMap);
router.emit("created", { type: "created", id: "1" });
// router.emit("created", { type: "deleted", id: "1", hard: true }); // 에러
이 방식의 장점은 다음과 같습니다.
satisfies로 핸들러 맵의 “형태”를 검증한다emit에서 키와 payload의 상관관계를 제네릭으로 강제한다- 호출부가 자연스럽게 타입 안전해진다
결론
TS 5.6에서 satisfies를 썼는데 타입 좁히기가 실패한다면, 대부분 satisfies를 “좁히기 도구”로 오해했거나 Record/인덱싱/판별자 widening으로 상관관계가 끊긴 경우입니다.
정리하면 이렇게 가져가면 안정적입니다.
satisfies: 형태 검증(컴파일 타임 계약) 용도- 타입 좁히기: 판별자 리터럴, 타입 가드, 매핑 타입, 제네릭 상관관계로 해결
- 인덱싱 기반 API는
dispatch(type, payload)형태로 재설계
satisfies를 올바른 자리에 두면, 타입을 과하게 단언하지 않으면서도(불필요한 as 없이) 유지보수성이 높은 타입 설계를 만들 수 있습니다.