- Published on
TS 5.5에서 is로 narrowing 안 될 때 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 장애를 디버깅할 때처럼, 타입 narrowing 문제도 증상은 비슷해 보여도 원인은 다양합니다. 특히 TS 5.5에서 사용자 정의 타입 가드(x is T)를 썼는데도 if 블록 안에서 타입이 안 줄어드는 상황은 꽤 자주 만납니다.
이 글은 "왜 narrowing이 안 되는가"를 7가지로 쪼개서, 각각을 재현 코드와 해결 패턴으로 정리합니다.
참고로 원인 분석 방식은 다른 트러블슈팅 글과 유사합니다. 예를 들어 네트워크 타임아웃을 진단할 때도 체크리스트로 원인을 분해하듯이요. 비슷한 접근이 궁금하면 Go gRPC DEADLINE_EXCEEDED 9가지 원인과 처방도 함께 보셔도 좋습니다.
1) 타입 가드 시그니처가 boolean으로 추론되어버림
가장 흔한 케이스입니다. 타입 가드를 만들었다고 생각했지만, 실제로는 반환 타입이 그냥 boolean인 함수가 되어버린 경우입니다.
재현
type Cat = { kind: "cat"; meow(): void };
type Dog = { kind: "dog"; bark(): void };
type Pet = Cat | Dog;
// 실수: 반환 타입을 명시하지 않아도 되는 경우가 많지만,
// 특정 상황(오버로드, 래핑, 재할당 등)에서 boolean으로 굳을 수 있습니다.
const isCat = (p: Pet) => p.kind === "cat";
function f(p: Pet) {
if (isCat(p)) {
p.meow();
// TS가 여기서 p를 Cat으로 보지 못하는 상황이 발생할 수 있음
}
}
해결
const isCat = (p: Pet): p is Cat => p.kind === "cat";
추가로, 타입 가드를 다른 변수에 담아 "일반 boolean 함수"처럼 취급되면 좁혀지지 않는 경우가 있으니, 가능하면 타입 가드 시그니처를 유지한 채로 전달하세요.
2) 제네릭 타입 가드가 T를 제대로 고정하지 못함
제네릭을 섞으면 is가 있어도 narrowing이 기대만큼 안 일어나는 경우가 있습니다. 핵심은 T가 너무 넓게 남아 있거나, 호출 시점에 구체 타입으로 고정되지 않는다는 점입니다.
재현
type Success = { ok: true; value: string };
type Failure = { ok: false; error: Error };
type Result = Success | Failure;
function isOk<T extends { ok: boolean }>(x: T) {
return x.ok === true;
}
function g(r: Result) {
if (isOk(r)) {
// r이 Success로 줄어들길 기대하지만,
// isOk는 boolean 반환으로 보이거나 T가 좁혀지지 않아 실패할 수 있음
}
}
해결
반환 타입을 x is ...로 정확히 표현해야 합니다.
function isSuccess(r: Result): r is Success {
return r.ok === true;
}
제네릭을 꼭 써야 한다면, "어떤 타입으로 좁힐지"를 타입 레벨에서 표현할 수 있어야 합니다. 예를 들어 Extract를 사용합니다.
function hasOkTrue<T extends { ok: boolean }>(x: T): x is Extract<T, { ok: true }> {
return x.ok === true;
}
3) filter에 타입 가드를 넣었는데 배열 타입이 안 줄어듦
Array.prototype.filter는 타입 가드와 궁합이 좋아 보이지만, 콜백 시그니처가 살짝만 어긋나도 결과 타입이 그대로 남습니다.
재현
type Item = { kind: "a"; a: number } | { kind: "b"; b: string };
const isA = (x: Item): x is Extract<Item, { kind: "a" }> => x.kind === "a";
const xs: Item[] = [{ kind: "a", a: 1 }, { kind: "b", b: "x" }];
const onlyA = xs.filter((x) => isA(x));
// 환경/상황에 따라 onlyA가 여전히 Item[]로 남는 경우가 있음
해결
콜백 자체를 타입 가드로 만들면 가장 안전합니다.
const onlyA = xs.filter(isA);
또는 콜백의 반환 타입이 boolean으로 흐려지지 않게 유지하세요. 특히 !!isA(x) 같은 패턴은 타입 가드 정보를 잃게 만들 수 있습니다.
4) 타입 가드 내부에서 파라미터를 변형하거나 별칭을 만들어 정보가 끊김
타입 가드는 "입력 파라미터 그 자체"를 좁히는 계약입니다. 그런데 가드 함수 안에서 파라미터를 다른 변수로 옮기거나, 변형하거나, 구조 분해를 과하게 하면 TS가 그 계약을 신뢰하기 어려워집니다.
재현
type User = { kind: "user"; id: string } | { kind: "guest" };
type RealUser = Extract<User, { kind: "user" }>;
function isRealUser(u: User): u is RealUser {
const { kind } = u;
return kind === "user";
}
function h(u: User) {
if (isRealUser(u)) {
u.id; // 보통은 되지만, 복잡한 케이스에서 narrowing이 깨지는 트리거가 될 수 있음
}
}
해결
가드 내부는 가능하면 "직접 접근" 형태로 단순하게 유지하세요.
function isRealUser(u: User): u is RealUser {
return u.kind === "user";
}
또한 가드 함수는 부수효과 없이 순수하게 작성하는 편이 안정적입니다.
5) 유니온이 아니라 인터섹션, 혹은 너무 넓은 상위 타입이라 좁힐 게 없음
is는 주로 유니온을 좁힐 때 효과가 큽니다. 그런데 입력 타입이 이미 any, unknown, 혹은 인터섹션 등으로 되어 있으면 "좁힐 수 있는 공간"이 없거나, 반대로 너무 넓어서 안전하게 좁힐 수 없습니다.
재현 1: any
function isString(x: unknown): x is string {
return typeof x === "string";
}
function k(x: any) {
if (isString(x)) {
x.toUpperCase();
// any는 원래 무엇이든 가능하므로, narrowing의 이점이 약해짐
}
}
재현 2: unknown에서 프로퍼티 접근을 먼저 함
function isHasId(x: unknown): x is { id: string } {
// 아래처럼 바로 접근하면 타입 가드 자체가 성립하지 않음
// return x.id !== undefined;
return typeof x === "object" && x !== null && "id" in x;
}
해결
- 입력을
unknown으로 받고, 가드에서 안전하게 체크한 뒤 좁히는 패턴을 고수 any가 섞이면 narrowing이 무의미해지므로, 경계면에서unknown으로 바꾸고 검증
6) 판별식(discriminant)이 boolean이나 string 같은 넓은 타입으로 선언됨
유니온을 잘 설계했는데도 narrowing이 안 된다면, 판별식 필드가 리터럴 타입이 아니라 넓은 타입으로 선언되어 있을 가능성이 큽니다.
재현
type A = { kind: string; a: number };
type B = { kind: string; b: string };
type U = A | B;
function isA(x: U): x is A {
return x.kind === "a";
}
function m(x: U) {
if (isA(x)) {
x.a; // 기대보다 덜 좁혀지는 문제를 유발
}
}
해결
판별식은 리터럴로 고정하세요.
type A = { kind: "a"; a: number };
type B = { kind: "b"; b: string };
type U = A | B;
이건 런타임 버그 예방에도 직결됩니다. 타입 수준에서 "구분 가능한 상태"를 만들지 못하면, 가드로도 안정적으로 좁히기 어렵습니다.
7) exactOptionalPropertyTypes, noUncheckedIndexedAccess 등 옵션 영향으로 조건이 충분하지 않음
TS 5.5 자체의 변화라기보다, TS 5.x에서 많이 채택되는 엄격 옵션 조합 때문에 "예전에는 되던 좁히기"가 깨진 것처럼 느껴질 수 있습니다.
대표적으로 optional 프로퍼티 체크가 생각보다 강하게 작동합니다.
재현
// tsconfig에서 exactOptionalPropertyTypes: true 라고 가정
type WithToken = { token?: string };
function hasToken(x: WithToken): x is { token: string } {
return x.token !== undefined;
}
function n(x: WithToken) {
if (hasToken(x)) {
x.token.toUpperCase();
// 상황에 따라 token이 string으로 완전히 고정되지 않는다고 느낄 수 있음
}
}
해결
가드에서 체크를 더 명확히 하거나, 타입을 설계 단계에서 분리합니다.
type Authed = { token: string };
type Unauthed = { token?: undefined };
type AuthState = Authed | Unauthed;
function isAuthed(x: AuthState): x is Authed {
return typeof x.token === "string";
}
또한 인덱스 접근이 T | undefined로 바뀌는 옵션(noUncheckedIndexedAccess)이 켜져 있다면, 배열이나 딕셔너리에서 꺼낸 값은 별도 체크 후 가드를 적용하는 식으로 순서를 조정해야 합니다.
디버깅 체크리스트(빠른 점검 순서)
문제가 생겼을 때는 아래 순서로 보면 빠릅니다.
- 타입 가드 함수의 반환 타입이 정말로
x is T인지 확인 - 가드를 다른 함수로 감싸거나
!!같은 변환을 하면서boolean으로 깨지지 않았는지 확인 - 입력 타입이 유니온인지, 판별식이 리터럴인지 확인
- 제네릭이 호출 시점에 구체화되는지(
T가 고정되는지) 확인 filter나 고차함수에 넘길 때 시그니처가 유지되는지 확인any가 섞여 있지 않은지 확인(경계에서unknown으로)- 엄격 옵션에서 optional, index access로
undefined가 끼는 경로가 있는지 확인
이런 식의 체크리스트 기반 접근은 다른 문제에도 그대로 적용됩니다. 예를 들어 API 400을 원인별로 쪼개는 방식은 OpenAI Responses API 400 에러 원인 8가지 같은 글에서도 동일한 구조로 확인할 수 있습니다.
마무리: is가 만능이 아니라 "계약"이라는 점을 기억하기
x is T는 "이 함수가 true를 반환하면, 호출자는 x를 T로 취급해도 좋다"라는 계약입니다. narrowing이 안 될 때는 대부분 이 계약이 타입 시스템 관점에서 성립하지 않거나(시그니처/설계 문제), 계약 정보가 전달 과정에서 손실되거나(래핑/콜백), 혹은 좁힐 대상이 애초에 불명확한 타입(any, 넓은 판별식)인 경우입니다.
위 7가지를 하나씩 제거해 나가면, TS 5.5에서도 타입 가드는 다시 예측 가능하게 동작합니다.