- Published on
TypeScript 5.5 is 연산자 좁히기 함정과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 형태의 데이터를 다루는 코드가 늘수록 is 타입 가드(user-defined type guard)는 “타입을 좁혀주는 마법”처럼 보입니다. 하지만 TypeScript 5.5 환경에서는 특히 타입 가드가 지나치게 낙관적으로 좁히거나, 반대로 전혀 좁혀지지 않는 상황이 생각보다 쉽게 발생합니다. 그리고 이 문제는 컴파일 에러가 아니라 “타입이 맞는 것처럼 보이는데 런타임에서 터지는” 형태로 나타나기 때문에 더 위험합니다.
이 글에서는 TypeScript 5.5에서 자주 마주치는 is 좁히기 함정들을 패턴별로 정리하고, 실무에서 안전하게 해결하는 방법(타입 가드 작성 규칙, 대체 설계)을 코드 중심으로 설명합니다. 타입 추론/제약 관련해서는 satisfies가 도움이 되는 경우도 많으니 함께 참고해도 좋습니다: TS 5.x satisfies로 타입 추론 깨짐 해결하기
배경: is 타입 가드는 “증명”이 아니라 “약속”이다
function f(x): x is T 형태의 타입 가드는 컴파일러에게 “이 함수가 true를 반환하면 x는 T다”라고 약속합니다. 중요한 점은 컴파일러가 그 약속이 맞는지 증명하지 않는다는 것입니다.
즉, 타입 가드 내부 구현이 부정확하면 타입 시스템이 오히려 버그를 숨겨 줍니다. TypeScript 5.5에서 달라진 점이 있다기보다, 5.5까지 오면서 코드베이스가 커지고 제네릭/유니온/구조적 타이핑이 복잡해지며 “약속의 구멍”이 더 자주 드러나는 편입니다.
함정 1: “그럴듯한 속성 체크”가 다른 타입까지 통과시킨다
구조적 타이핑(structural typing) 때문에 in 체크나 단순 프로퍼티 존재 여부만으로는 충분히 구분이 안 됩니다.
문제 예시: in 체크만으로는 식별이 안 된다
type Cat = { kind: "cat"; meow: () => void };
type Dog = { kind: "dog"; bark: () => void };
// 실수: kind를 안 보고, bark 존재 여부만 체크
function isDog(x: unknown): x is Dog {
return typeof x === "object" && x !== null && "bark" in x;
}
function handle(x: Cat | Dog) {
if (isDog(x)) {
x.bark();
} else {
x.meow();
}
}
겉으로는 괜찮아 보이지만, 런타임에서 bark라는 키가 “우연히” 존재하는 다른 객체가 들어오면 Dog로 오인합니다. 특히 외부 입력(JSON)이나 서드파티 객체가 섞이면 빈번합니다.
해결: 식별자(discriminant) 기반으로 좁혀라
가능하면 유니온에는 식별자 필드(kind, type, tag)를 두고 그 값으로 좁히는 게 가장 안전합니다.
type Cat = { kind: "cat"; meow: () => void };
type Dog = { kind: "dog"; bark: () => void };
type Animal = Cat | Dog;
function isDog(x: Animal): x is Dog {
return x.kind === "dog";
}
외부 입력을 받는 경우라면 unknown에서 Animal로 올리기 전에 “런타임 검증”을 별도로 두는 것이 안전합니다(아래 함정 4 참고).
함정 2: is 타입 가드가 제네릭과 만나면 “허용 범위”가 과도해진다
제네릭 타입 가드가 “어떤 T에 대해서도” 참/거짓을 말해버리면, 컴파일러는 그 말을 그대로 믿습니다. 그 결과 실제로는 불가능한 좁히기가 가능해져 런타임 예외로 이어질 수 있습니다.
문제 예시: 제네릭 가드가 항상 참처럼 동작하는 착시
function hasId<T extends object>(x: T): x is T & { id: string } {
return "id" in x;
}
function f<T extends object>(x: T) {
if (hasId(x)) {
// 여기서 x.id는 string으로 믿어짐
console.log(x.id.toUpperCase());
}
}
문제는 "id" in x는 id가 존재하기만 하면 통과한다는 점입니다. id: 123이어도 통과하고, 프로토타입 체인에 id가 있어도 통과합니다.
해결 1: 값의 타입까지 확인하고, own property인지도 확인
function hasStringId(x: unknown): x is { id: string } {
if (typeof x !== "object" || x === null) return false;
// own property + 타입 체크
if (!Object.prototype.hasOwnProperty.call(x, "id")) return false;
const v = (x as { id?: unknown }).id;
return typeof v === "string";
}
그 다음 제네릭 함수에서는 “제네릭을 좁히려는 욕심”을 줄이고, 안전한 형태로 분리합니다.
function f<T extends object>(x: T) {
if (hasStringId(x)) {
// 여기서의 x는 { id: string }로만 다루는 것이 안전
console.log(x.id.toUpperCase());
}
}
해결 2: 제네릭을 유지해야 한다면, 반환 타입을 과도하게 만들지 말기
실무에서 자주 하는 실수는 x is T & Something을 남발하는 것입니다. 정말로 T가 유지되어야 하는지(즉, T의 다른 필드들이 런타임에서도 보장되는지)부터 점검하세요.
함정 3: filter(isSomething)이 “원소 타입을 유지”한다고 착각한다
배열에서 자주 쓰는 패턴입니다.
const xs: Array<string | number | null> = ["a", 1, null, "b"];
function isString(x: unknown): x is string {
return typeof x === "string";
}
const onlyStrings = xs.filter(isString);
이건 잘 동작합니다. 그런데 아래처럼 제네릭/구조적 타입이 섞이면 함정이 생깁니다.
문제 예시: “부분 조건”으로 만든 타입 가드가 컬렉션 전체를 오염시킨다
type A = { type: "a"; value: string };
type B = { type: "b"; value: string; extra: number };
const items: Array<A | B> = [
{ type: "a", value: "x" },
{ type: "b", value: "y", extra: 1 },
];
// 실수: extra 존재 여부만 보면 B처럼 보이지만, 런타임에서 불완전한 객체가 섞일 수 있음
function isB(x: A | B): x is B {
return "extra" in x;
}
const bs = items.filter(isB);
// bs는 B[]로 보이므로 extra를 마음껏 사용
bs.forEach(b => console.log(b.extra.toFixed(0)));
현재 데이터는 안전해 보여도, 입력 경로가 바뀌거나 API 계약이 흔들리면 바로 깨집니다.
해결: 식별자 필드로 좁히고, 가능하면 type 기반으로 설계
function isB(x: A | B): x is B {
return x.type === "b";
}
const bs = items.filter(isB);
추가로, 객체 유니온은 가능한 한 “서로 겹치지 않는” 형태로 설계하는 게 유지보수에 유리합니다.
함정 4: unknown을 is로 한 번에 올리려다 런타임 검증이 부실해진다
외부 입력(JSON, 메시지 큐, localStorage, querystring 등)은 항상 unknown으로 보고 런타임 검증을 거쳐야 합니다. 그런데 is 타입 가드를 너무 빨리 “도메인 타입”으로 올려버리면, 이후 로직이 전부 타입에 속아버립니다.
문제 예시: 느슨한 검증
type User = {
id: string;
role: "admin" | "member";
};
function isUser(x: unknown): x is User {
if (typeof x !== "object" || x === null) return false;
return "id" in x && "role" in x;
}
role이 실제로는 "root"여도 통과합니다. id가 숫자여도 통과합니다.
해결: “검증 함수(parse)”와 “타입 가드(is)”를 분리
실무에서는 is보다 parse(검증 실패 시 예외 또는 결과 타입) 패턴이 더 안전합니다.
type User = {
id: string;
role: "admin" | "member";
};
function parseUser(x: unknown): User {
if (typeof x !== "object" || x === null) {
throw new Error("User must be an object");
}
const obj = x as { id?: unknown; role?: unknown };
if (typeof obj.id !== "string") {
throw new Error("User.id must be a string");
}
if (obj.role !== "admin" && obj.role !== "member") {
throw new Error("User.role must be admin or member");
}
return { id: obj.id, role: obj.role };
}
// 사용
const user = parseUser(JSON.parse("{\"id\":\"u1\",\"role\":\"admin\"}"));
이 방식은 “검증이 통과한 값만 도메인 타입이 된다”는 점에서, is의 약속 기반 모델보다 사고가 덜 납니다.
함정 5: asserts와 is를 섞을 때 생기는 불일치
TypeScript에는 asserts value is T 형태도 있습니다. 팀에 따라 is와 asserts가 혼재하면, 호출부는 타입이 안전해 보이지만 실제 검증이 부실한 경우가 생깁니다.
안전한 패턴: assert는 반드시 “실패 시 throw”가 보장되어야 한다
type Config = { port: number };
function assertConfig(x: unknown): asserts x is Config {
if (typeof x !== "object" || x === null) {
throw new Error("Config must be object");
}
const obj = x as { port?: unknown };
if (typeof obj.port !== "number") {
throw new Error("Config.port must be number");
}
}
function start(x: unknown) {
assertConfig(x);
// 여기부터 x는 Config
console.log(x.port.toFixed(0));
}
asserts는 “실패하면 멈춘다”가 전제이므로, 반환값으로 boolean을 섞어 애매하게 만들지 않는 게 좋습니다.
TypeScript 5.5에서 특히 조심할 지점: 타입 가드의 “범위”와 “재사용”
5.5 자체의 단일 기능 변화라기보다, 최근 TS 코드베이스에서 흔한 스타일 때문에 함정이 증폭됩니다.
- 공용 유틸로 만든 타입 가드가 여러 도메인에서 재사용되며, 실제로는 더 강한 제약이 필요한데도 느슨한 체크가 유지됨
- 제네릭 타입 가드가
T & ...형태로 “너무 많은 것을 보장”해버림 - 외부 입력을
unknown에서 도메인 타입으로 올릴 때is하나로 끝내려는 유혹
이런 문제는 성능 이슈처럼 “눈에 보이는 지표”가 아니라, 장애 상황에서만 터지는 경우가 많습니다. 운영 장애를 줄이는 관점에서는 인증/검증도 비슷한 성격이 있으니, 검증 실패 케이스를 촘촘히 보는 습관이 도움이 됩니다: Node.js JWT 검증 실패 - kid·JWKS 캐시 만료 대응
실전 체크리스트: 안전한 is 타입 가드 작성 규칙
다음 규칙을 팀 컨벤션으로 박아두면 “타입은 맞는데 런타임에서 터지는” 케이스가 크게 줄어듭니다.
- 가능하면 식별자 기반(discriminated union)으로 좁히기
kind,type,tag같은 필드를 두고 값 비교로 좁히기
in만 쓰지 말고 값의 타입까지 확인하기typeof obj.prop === "string"같은 식으로
- 가능하면 own property 확인하기
Object.prototype.hasOwnProperty.call(obj, "prop")
unknown에서 도메인 타입으로는parse또는assert를 선호- 실패 시 에러를 명확히
- 제네릭 타입 가드의 반환 타입을 과도하게 만들지 않기
x is T & Something은 정말로 보장 가능한지 점검
- 타입 가드에는 테스트를 붙이기
- 특히 “거짓 양성(false positive)” 케이스를 넣기
예제: 함정을 피하는 타입 가드/파서 템플릿
아래는 외부 입력을 안전하게 다루는 데 자주 쓰는 템플릿입니다.
type Event =
| { type: "click"; x: number; y: number }
| { type: "view"; url: string };
function parseEvent(x: unknown): Event {
if (typeof x !== "object" || x === null) {
throw new Error("Event must be object");
}
const obj = x as { type?: unknown; x?: unknown; y?: unknown; url?: unknown };
if (obj.type === "click") {
if (typeof obj.x !== "number" || typeof obj.y !== "number") {
throw new Error("click event must have numeric x,y");
}
return { type: "click", x: obj.x, y: obj.y };
}
if (obj.type === "view") {
if (typeof obj.url !== "string") {
throw new Error("view event must have string url");
}
return { type: "view", url: obj.url };
}
throw new Error("unknown event type");
}
function handleEvent(raw: unknown) {
const e = parseEvent(raw);
// 여기부터는 TypeScript가 정확히 좁혀줌
if (e.type === "click") {
console.log(e.x + e.y);
} else {
console.log(e.url.toLowerCase());
}
}
핵심은 is로 “그럴듯하게” 좁히는 대신, 런타임에서 진짜로 보장되는 조건만 통과시키는 것입니다.
마무리
TypeScript 5.5에서 is 타입 가드는 여전히 강력하지만, 그 본질은 “컴파일러가 믿는 약속”입니다. 약속이 느슨하면 좁히기가 편해지는 대신, 런타임 안정성은 떨어집니다.
- 유니온은 식별자 기반으로 설계
unknown은parse또는assert로 올리기- 제네릭 가드의 반환 타입을 과하게 만들지 않기
이 3가지만 지켜도 is 좁히기 함정의 대부분을 피할 수 있습니다.