- Published on
TS 5.6 `satisfies`로 타입 좁히기 실패 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀/레포에서 공통 설정 객체를 공유할 때 satisfies는 “형태 검증은 하되, 값의 구체 타입은 최대한 보존”하기 위한 최고의 도구입니다. 그런데 TS 5.6로 업그레이드한 뒤, 분명히 satisfies를 붙였는데도 분기문에서 타입이 좁혀지지 않거나, 키 접근이 string/unknown으로 남아버리는 상황을 자주 만납니다.
이 글은 “satisfies는 타입 좁히기를 해준다”라는 막연한 기대가 깨지는 지점을 정확히 짚고, 실패 패턴별로 재현 코드와 해결책을 정리합니다. 결론부터 말하면 satisfies는 검증(validation) 도구이지, 항상 협소화(narrowing) 를 보장하는 도구는 아닙니다. 좁히기는 결국 “리터럴 보존”, “discriminated union”, “타입 가드/단언(assertion)” 같은 다른 축이 담당합니다.
참고로, 런타임 버그를 정규식 플래그 하나로 잡는 것처럼 타입 시스템도 작은 선택이 큰 차이를 만듭니다. 비슷한 결의 글로 ES2024 RegExp v 플래그로 유니코드 버그 잡기도 함께 보면 좋습니다.
satisfies의 정확한 역할: “검증”과 “추론 유지”
satisfies는 다음 두 가지를 동시에 노립니다.
- 좌변 값이 우변 타입을 만족하는지 컴파일 타임에 검사한다.
- 좌변 값의 추론 타입을 강제로 우변으로 바꾸지 않는다.
즉 as SomeType처럼 “우변 타입으로 캐스팅”하지 않고, “형태가 맞는지 확인만” 합니다.
type RouteConfig = {
path: string;
auth?: "public" | "user" | "admin";
};
const route = {
path: "/settings",
auth: "admin",
} satisfies RouteConfig;
// route.auth의 타입은 "admin" | undefined 로 보존될 가능성이 큼
여기서 중요한 포인트는 satisfies가 분기문에서의 좁히기를 만들어내는 게 아니라, “좁힐 수 있는 재료(리터럴, 유니온 구조)를 얼마나 보존하느냐”에 더 가깝다는 점입니다.
실패 패턴 1: Record<string, ...>로 만족시키면 키가 string으로 굳는다
가장 흔한 실패는 설정 맵을 아래처럼 정의하는 경우입니다.
type Feature = {
enabled: boolean;
rollout: number;
};
const features = {
search: { enabled: true, rollout: 100 },
lab: { enabled: false, rollout: 10 },
} satisfies Record<string, Feature>;
// 문제: 키는 이제 그냥 string 취급
function getFeature(name: string) {
return features[name];
// 반환 타입: Feature | undefined (혹은 Feature)
// "search" | "lab" 같은 리터럴 키 정보가 활용되지 않음
}
왜 좁히기가 실패하나
Record<string, Feature>는 “임의의 문자열 키가 가능”하다는 의미입니다. 따라서 features가 실제로는 search와 lab만 갖고 있어도, 타입 시스템 입장에서는 “다른 키도 가능”하므로 keyof typeof features가 좁혀질 근거가 약해집니다.
해결 1: 키 리터럴을 살리고 싶으면 as const + 구체 타입 만족
type FeaturesShape = Record<string, Feature>;
const features = {
search: { enabled: true, rollout: 100 },
lab: { enabled: false, rollout: 10 },
} as const satisfies FeaturesShape;
type FeatureName = keyof typeof features; // "search" | "lab"
function getFeature(name: FeatureName) {
return features[name];
}
핵심은 as const로 키와 값의 리터럴을 고정해두고, satisfies는 “형태 검증”만 시키는 겁니다.
해결 2: 아예 name을 keyof typeof features로 강제
외부 입력(string)을 그대로 받으면 타입 좁히기는 불가능합니다. 이때는 런타임 체크 + 타입 가드를 붙여야 합니다.
function isFeatureName(x: string): x is keyof typeof features {
return x in features;
}
function getFeatureSafe(name: string) {
if (!isFeatureName(name)) return null;
return features[name];
}
실패 패턴 2: 값이 유니온인데, 판별자(discriminant)가 리터럴로 유지되지 않는다
satisfies를 쓰더라도, 판별자 필드가 string으로 넓어지면 좁히기가 깨집니다.
type Handler =
| { kind: "http"; url: string }
| { kind: "queue"; topic: string };
const handlers = {
a: { kind: "http", url: "https://api" },
b: { kind: "queue", topic: "events" },
} satisfies Record<string, Handler>;
const h = handlers.a;
if (h.kind === "http") {
h.url;
// 기대: string
// 실제: 좁혀지지 않거나, url 접근이 에러/불완전한 경우를 종종 만남
}
원인
Record<string, Handler>자체가 키를 넓히는 문제도 있고- 객체 리터럴이 컨텍스트 타입(contextual typing)으로 인해 내부 필드가 넓어지는 경우가 있습니다(특히 중간 변수에 담거나, 함수 반환으로 감싸면 자주 발생).
해결: as const로 판별자 리터럴을 고정
const handlers = {
a: { kind: "http", url: "https://api" },
b: { kind: "queue", topic: "events" },
} as const satisfies Record<string, Handler>;
const h = handlers.a;
if (h.kind === "http") {
h.url; // string
}
as const는 과하게 느껴질 수 있지만, “설정/매핑 테이블”처럼 런타임에 바뀌지 않는 값에는 오히려 안전합니다.
실패 패턴 3: satisfies는 “변수 자체”를 좁히지 않는다
다음 코드는 많은 사람이 기대하는 패턴이지만, satisfies만으로는 원하는 좁히기가 나오지 않습니다.
type User = { id: string; role: "user" };
type Admin = { id: string; role: "admin"; permissions: string[] };
type Person = User | Admin;
const p = {
id: "1",
role: "admin",
permissions: ["write"],
} satisfies Person;
// p는 "Admin"으로 좁혀질까?
// 보통은 "{ id: string; role: "admin"; permissions: string[] }" 같은
// '추론된 객체 리터럴 타입'이지, Admin으로 자동 변환되지 않는다.
해결 1: 유니온으로 “맞추기”가 목적이면 satisfies가 아니라 명시적 타입/헬퍼를 고려
Person으로 다루고 싶다면 애초에 변수 타입을 Person으로 선언해야 합니다.
const p: Person = {
id: "1",
role: "admin",
permissions: ["write"],
};
if (p.role === "admin") {
p.permissions; // string[]
}
이 경우 “추론 타입 보존”보다는 “유니온으로 다루기”가 우선이기 때문입니다.
해결 2: satisfies를 유지하면서 유니온으로 취급하고 싶다면 as Person을 의도적으로 사용
as는 위험하다고 알려져 있지만, “이미 객체 리터럴이고 구조가 명백”한 경우에는 오히려 의도가 선명합니다.
const p = ({
id: "1",
role: "admin",
permissions: ["write"],
} satisfies Person) as Person;
if (p.role === "admin") {
p.permissions;
}
여기서 핵심은 satisfies로 검증을 걸어둔 상태에서 as를 쓰는 것입니다. 즉 “눈 가리고 캐스팅”이 아니라, 검증 후 단언이 됩니다.
실패 패턴 4: 제네릭 함수 경계에서 리터럴이 소실된다
TS에서 “리터럴 유지”는 함수 경계를 지나면 쉽게 깨집니다.
type Rule = { level: "off" | "warn" | "error" };
type Rules = Record<string, Rule>;
function buildRules(r: Rules) {
return r;
}
const rules = buildRules({
noFoo: { level: "error" },
noBar: { level: "warn" },
} satisfies Rules);
// rules.noFoo.level이 "error"로 남길 기대하지만,
// buildRules의 파라미터가 Rules로 고정되어 있으면 보통 넓어짐
해결: 제네릭으로 “입력 타입을 그대로” 들고 오기
function buildRules<T extends Rules>(r: T) {
return r;
}
const rules = buildRules({
noFoo: { level: "error" },
noBar: { level: "warn" },
} as const satisfies Rules);
// rules.noFoo.level: "error"
T extends Rules로 “Rules를 만족하는 구체 타입 T”를 보존as const로 리터럴 보존satisfies Rules로 형태 검증
이 조합이 설정 객체를 다루는 데 가장 안정적입니다.
실패 패턴 5: 인덱싱 결과가 unknown/never로 꼬일 때
특히 noUncheckedIndexedAccess가 켜져 있거나, 키가 넓게 잡히면 인덱싱 결과가 T | undefined로 늘어나고, 그 다음 좁히기가 연쇄적으로 실패합니다.
const map = {
a: { mode: "on" as const },
b: { mode: "off" as const },
} satisfies Record<string, { mode: "on" | "off" }>;
function read(k: string) {
const v = map[k];
// v: { mode: "on" | "off" } | undefined
if (v?.mode === "on") {
// 여기서도 v가 undefined일 수 있다는 흐름이 남아 복잡해짐
}
}
해결: 단계적으로 “키 검증” 후 인덱싱
function hasKey<K extends string>(
obj: Record<string, unknown>,
key: K,
): key is K & keyof typeof obj {
return key in obj;
}
function read(k: string) {
if (!hasKey(map, k)) return;
const v = map[k];
// v는 이제 undefined가 아닌 쪽으로 훨씬 다루기 쉬움
if (v.mode === "on") {
// v.mode는 "on"으로 좁혀짐
}
}
현업에서는 이런 패턴이 “외부 입력을 안전하게 내부 키로 바꾸는 관문”이 됩니다. 장애를 줄이는 관점에서 보면, 비슷한 결로 리소스 누수를 막는 패턴을 정리한 Go 고루틴 누수 잡기 - context·채널 종료 패턴도 참고할 만합니다.
TS 5.6에서 특히 자주 보이는 착각 3가지
1) satisfies를 붙이면 자동으로 유니온 멤버로 “캐스팅”될 거라는 기대
satisfies는 캐스팅이 아닙니다. 유니온으로 다루고 싶으면 변수 선언 타입을 유니온으로 주거나, 검증 후 단언을 하세요.
2) Record<string, ...>는 편하지만, 리터럴 키 기반 설계를 망가뜨릴 수 있다
설정 객체를 “정적 테이블”로 쓸 거면 as const + keyof typeof 설계가 더 강력합니다.
3) 분기 좁히기는 “판별자 리터럴”이 생명이다
kind, type, tag 같은 필드가 string으로 넓어지는 순간, 좁히기는 무너집니다. 함수 경계/중간 변수에서 넓어지지 않게 관리해야 합니다.
실전 레시피: 설정 테이블을 안전하게 만드는 표준 패턴
아래 패턴은 프론트/백엔드 공통으로 가장 재사용성이 좋습니다.
as const로 리터럴 보존satisfies로 스키마 검증keyof typeof로 키 유니온 생성- 외부 입력은 타입 가드로 검증 후 내부로 들여보냄
type Endpoint = {
method: "GET" | "POST";
path: string;
auth: "public" | "user" | "admin";
};
type EndpointTable = Record<string, Endpoint>;
export const endpoints = {
getProfile: { method: "GET", path: "/me", auth: "user" },
adminStats: { method: "GET", path: "/admin/stats", auth: "admin" },
} as const satisfies EndpointTable;
export type EndpointName = keyof typeof endpoints;
export function isEndpointName(x: string): x is EndpointName {
return x in endpoints;
}
export function resolveEndpoint(name: string) {
if (!isEndpointName(name)) throw new Error("Unknown endpoint");
const ep = endpoints[name];
// ep.auth는 "user" | "admin" 같은 리터럴 유니온으로 유지
if (ep.auth === "admin") {
// admin 전용 로직
}
return ep;
}
이렇게 해두면 “테이블은 정적이고, 입력은 동적”이라는 현실을 타입 시스템이 자연스럽게 따라오게 됩니다.
디버깅 체크리스트: 좁히기 실패 시 무엇을 의심할까
keyof typeof obj가 원하는 리터럴 유니온인가, 아니면string인가- 판별자(
kind/type)가 리터럴 유니온으로 남아 있는가 - 함수 파라미터가 너무 넓은 타입으로 고정되어 추론을 죽이고 있지 않은가
- 인덱싱이
T | undefined로 퍼지면서 후속 좁히기를 방해하고 있지 않은가 - 정말로
satisfies가 필요한가, 아니면 변수 선언 타입을 명시하는 편이 더 의도에 맞는가
이 체크리스트는 “타입이 생각보다 넓다”는 문제를 빠르게 원인 분리하는 데 도움이 됩니다. 대규모 시스템에서 원인 추적이 중요한 건 타입 문제도 마찬가지인데, 장애 원인 추적/디버깅 관점에서는 MySQL 8.0 InnoDB 데드락 원인추적·해결 실전처럼 ‘관찰 포인트를 체계화’하는 접근이 결국 시간을 절약합니다.
마무리
TS 5.6에서 satisfies는 여전히 강력하지만, “타입 좁히기”를 직접 수행하는 도구가 아니라는 점을 명확히 이해해야 합니다. 좁히기가 실패할 때는 대개 다음 중 하나입니다.
- 키가
string으로 넓어짐(Record<string, ...>남용) - 판별자가 리터럴로 유지되지 않음(
as const부재, 함수 경계에서 소실) - 유니온으로 다루고 싶은데 변수 타입을 유니온으로 선언하지 않음
- 외부 입력을 타입 가드 없이 내부 키로 바로 사용함
해결은 어렵지 않습니다. as const로 리터럴을 고정하고, satisfies로 스키마를 검증하고, 필요한 곳에는 타입 가드/asserts로 런타임 검증을 붙이세요. 그러면 “검증은 엄격하게, 사용은 편하게”라는 satisfies의 장점을 제대로 살릴 수 있습니다.