- Published on
TS 5.5 타입 좁힘이 안 될 때 - is·satisfies
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버/프론트 공통으로 TS를 쓰다 보면, “분명히 if로 체크했는데 왜 아직도 unknown이야?”, “filter(Boolean) 했는데 왜 undefined가 남아있지?” 같은 순간이 주기적으로 옵니다. 특히 TypeScript 5.5로 올린 뒤 이런 체감이 커졌다는 이야기가 종종 나오는데, 실제로는 TS가 더 엄격해졌다기보다는 우리가 기대하던 좁힘이 특정 패턴에서 성립하지 않는 것에 가깝습니다.
이 글에서는 TS 5.5에서 자주 마주치는 “타입 좁힘이 안 되는” 상황을 몇 가지 패턴으로 정리하고, 실무에서 가장 확실한 해결책인 is 타입 가드와 satisfies를 이용해 컴파일러가 이해할 수 있는 형태로 의도를 전달하는 방법을 다룹니다.
참고로, 이런 타입 문제는 런타임 장애로도 이어질 수 있으니(예: 잘못된 데이터로 렌더링 루프/예외 발생), 성능/안정성 트러블슈팅 관점에서는 Next.js 14 RSC 캐시 무효화로 데이터 꼬임 해결 같은 글과 함께 “데이터가 어디서 꼬였는지”를 같이 보는 습관이 도움이 됩니다.
왜 TS는 내가 원하는 대로 좁혀주지 않을까
TypeScript의 좁힘(narrowing)은 “내가 논리적으로 맞다고 생각하는 것”이 아니라, 컴파일러가 증명할 수 있는 것만 반영합니다. 좁힘이 실패하는 대표적인 이유는 아래입니다.
- 콜백 경계:
Array.prototype.filter같은 고차 함수 콜백 내부에서의 조건이, 콜백 외부 결과 타입에 자동 반영되지 않는 경우 - 구조적 검사 한계:
in/typeof/truthy 체크만으로는 원하는 도메인 타입까지 증명되지 않는 경우 - 유니온 설계 문제: 판별자(discriminant)가 불명확하거나, 객체 리터럴이 “너무 넓게” 추론되는 경우
TS 5.5에서는 특히 배열 메서드의 콜백과 관련된 추론에서 “내가 기대한 결과 타입”과 “TS가 보장 가능한 결과 타입” 사이의 간극이 더 잘 드러납니다.
케이스 1: filter(Boolean) 했는데도 undefined가 남는 이유
가장 흔한 패턴입니다.
const xs = [0, 1, 2, undefined];
const ys = xs.filter(Boolean);
// 기대: number[]
// 실제: (number | undefined)[] (또는 더 넓은 타입)
Boolean은 단지 (value: any) => boolean 시그니처를 가진 함수일 뿐이고, TS 입장에서는 “이 함수가 무엇을 제거하는지”를 타입 수준에서 알 수 없습니다. 그래서 결과 배열이 undefined가 제거되었다고 증명할 수 없고, 유니온이 그대로 남습니다.
해결 1) is 타입 가드로 명시하기
type NonNullableValue<T> = T extends null | undefined ? never : T;
function isNonNullable<T>(v: T): v is NonNullableValue<T> {
return v !== null && v !== undefined;
}
const xs = [0, 1, 2, undefined];
const ys = xs.filter(isNonNullable);
// ys: number[]
핵심은 반환 타입 v is ... 입니다. 이 한 줄이 “이 함수가 참을 반환하면 v는 이 타입이다”라는 정보를 컴파일러에게 제공합니다.
해결 2) truthy가 목적이면 그에 맞는 가드 작성
0도 제거하고 싶지 않은데 Boolean을 쓰면 0이 falsy라서 제거됩니다. 반대로 “falsy 제거”가 목적이라면 타입도 그에 맞춰야 합니다.
type Truthy<T> = T extends 0 | "" | false | null | undefined ? never : T;
function isTruthy<T>(v: T): v is Truthy<T> {
return Boolean(v);
}
const xs = [0, 1, 2, undefined, "", "ok"];
const ys = xs.filter(isTruthy);
// ys: (1 | 2 | "ok")[] 처럼 더 좁아짐(상황에 따라)
케이스 2: map에서 undefined 만들고 filter로 지웠는데도 좁힘이 안 됨
실무에서 매우 자주 나오는 형태입니다.
type User = { id: string; name: string };
const users: User[] = [
{ id: "1", name: "A" },
{ id: "2", name: "B" },
];
const names = users
.map(u => (u.name.startsWith("A") ? u.name : undefined))
.filter(Boolean);
// 기대: string[]
// 실제: (string | undefined)[]
위와 같은 이유로 Boolean은 좁힘을 제공하지 않습니다.
해결: 타입 가드로 파이프라인을 “타입 친화적”으로 만들기
function isString(v: unknown): v is string {
return typeof v === "string";
}
const names = users
.map(u => (u.name.startsWith("A") ? u.name : undefined))
.filter(isString);
// names: string[]
여기서 isString은 재사용 가치가 높습니다. API 응답 파싱, 폼 입력 검증, 로그/메트릭 태깅 등에서도 동일하게 쓰입니다.
케이스 3: 객체 유니온인데 in 체크 후에도 원하는 좁힘이 안 됨
예를 들어 API 응답이 아래처럼 온다고 합시다.
type Ok = { ok: true; data: { id: string } };
type Fail = { ok: false; error: { message: string } };
type Result = Ok | Fail;
function handle(r: Result) {
if ("data" in r) {
// 여기서 r은 Ok로 좁혀질 것 같지만,
// 구조가 복잡해지면 기대만큼 안 좁혀지는 경우가 생김
console.log(r.data.id);
}
}
in 체크는 유용하지만, 실무에서는 필드가 optional이거나(예: data?), 여러 타입에서 공유되는 키가 섞이면서 “완전한 판별”이 어렵습니다.
해결: 판별자를 확실히 두고, 그 판별자 기준으로 좁히기
type Ok = { type: "ok"; data: { id: string } };
type Fail = { type: "fail"; error: { message: string } };
type Result = Ok | Fail;
function handle(r: Result) {
if (r.type === "ok") {
console.log(r.data.id);
return;
}
console.log(r.error.message);
}
유니온이 길어질수록 type 같은 명시적 판별자가 유지보수성과 타입 안정성을 동시에 올립니다.
satisfies: “추론은 유지하고, 형태만 검증”하기
is가 런타임 체크를 수반하는 “타입 좁힘 도구”라면, satisfies는 객체 리터럴/상수 정의에서 타입을 깨끗하게 잡는 도구입니다.
특히 TS에서 흔한 실수는 “타입을 맞추려고 as를 남발하면서” 실제 값이 틀려도 컴파일이 통과하는 것입니다. satisfies는 반대로 형태 검증은 엄격하게 하되, 값의 리터럴 추론은 최대한 유지합니다.
예시 1) 라우트 테이블/설정 객체에서 as 대신 satisfies
type RouteConfig = {
path: string;
auth: "public" | "private";
};
type Routes = Record<string, RouteConfig>;
const routes = {
home: { path: "/", auth: "public" },
me: { path: "/me", auth: "private" },
// typo가 있으면 여기서 바로 잡힘
// admin: { path: "/admin", auth: "privte" },
} satisfies Routes;
// routes.home.auth는 "public" 리터럴로 유지됨
as Routes로 강제 캐스팅하면 오타가 있어도 통과할 수 있지만, satisfies Routes는 오타를 에러로 잡습니다.
예시 2) 유니온 판별자와 함께 쓰면 더 강력
type Event =
| { type: "click"; x: number; y: number }
| { type: "view"; url: string };
const eventHandlers = {
click: (e: Extract<Event, { type: "click" }>) => {
return `${e.x},${e.y}`;
},
view: (e: Extract<Event, { type: "view" }>) => {
return e.url;
},
} satisfies Record<Event["type"], (e: any) => string>;
여기서 satisfies는 “키가 click/view를 모두 가져야 한다” 같은 구조를 강제하는 데 도움이 됩니다.
is와 satisfies를 같이 쓰는 실전 패턴: API 응답 파싱
실무에서 타입 좁힘 문제가 가장 치명적인 곳은 “외부 입력(네트워크/스토리지/폼)”입니다. 이때는 아래 2단계가 안정적입니다.
satisfies로 내가 만드는 요청/설정/스키마를 안전하게 유지is타입 가드로 외부에서 들어오는 값을 좁혀서 내부 도메인 타입으로 변환
type ApiUser = { id: string; name: string };
function isApiUser(v: unknown): v is ApiUser {
if (typeof v !== "object" || v === null) return false;
const o = v as Record<string, unknown>;
return typeof o.id === "string" && typeof o.name === "string";
}
async function fetchUsers(): Promise<ApiUser[]> {
const res = await fetch("/api/users");
const data: unknown = await res.json();
if (!Array.isArray(data)) return [];
return data.filter(isApiUser);
}
const query = {
limit: 50,
sort: "desc",
} satisfies { limit: number; sort: "asc" | "desc" };
이 패턴은 런타임 안정성에도 직접 연결됩니다. 데이터가 꼬이는 문제는 성능 지표에도 영향을 주기 쉬운데(불필요한 재시도, 렌더링 폭증 등), 프론트 성능 이슈를 함께 다룰 때는 Chrome INP 급락 원인 찾기 - Long Task 추적 같은 접근과 병행하면 원인 분리가 빨라집니다.
TS 5.5에서 특히 “좁힘이 안 된다”고 느끼는 지점 체크리스트
아래 항목 중 하나라도 해당하면, 컴파일러가 좁힘을 못 하는 게 정상일 가능성이 큽니다.
filter(Boolean)또는filter(x => x)같은 “의미는 알겠는데 타입 정보가 없는” 콜백을 사용했다map에서undefined/null을 섞어 만들고, 뒤에서 대충 제거하려 했다- 유니온 타입에 판별자가 없거나, 판별자 대신
in/truthy 같은 간접 조건에 의존한다 - 객체 리터럴을
as SomeType로 덮어서, 추론/검증이 무력화되어 있다
이럴 때의 정답은 대체로 두 가지입니다.
- 런타임 체크가 필요한 경계에서는
is타입 가드로 “좁힘 규칙”을 함수로 승격 - 설정/테이블/상수 정의에서는
satisfies로 “형태 검증”을 강제하고 추론은 유지
자주 쓰는 유틸 모음
실무에서 재사용하기 좋은 최소 유틸을 정리하면 아래 정도가 베이스가 됩니다.
export type NonNullableValue<T> = T extends null | undefined ? never : T;
export function isNonNullable<T>(v: T): v is NonNullableValue<T> {
return v !== null && v !== undefined;
}
export function isString(v: unknown): v is string {
return typeof v === "string";
}
export function isNumber(v: unknown): v is number {
return typeof v === "number" && !Number.isNaN(v);
}
이 유틸만 제대로 써도 “TS가 타입 좁힘을 안 해준다” 류의 이슈는 대부분 사라집니다.
결론: TS가 못 알아듣는 표현을, TS가 알아듣는 형태로 바꾸자
TS 5.5에서 타입 좁힘이 안 되는 것처럼 보이는 문제는, 대개 코드가 나쁜 게 아니라 컴파일러가 증명할 수 없는 방식으로 의도를 표현한 것에 가깝습니다.
is는 “이 조건이 참이면 이 타입이다”를 컴파일러에게 직접 가르치는 방법satisfies는 “이 객체는 이 형태를 만족해야 한다”를 강제하면서도 리터럴 추론을 살리는 방법
두 도구를 적절히 섞으면, 타입 안전성과 개발 속도를 동시에 챙길 수 있습니다.