
TypeScript를 사용하다 보면, 겉보기에는 동일해 보이지만 실제로는 다르게 평가되는 타입을 마주치는 경우가 있다. 특히 객체 타입과 인터섹션 타입을 함께 다룰 때 이런 차이가 분명하게 드러난다.
다음 두 타입을 살펴보자.
type A = { x: string; y: string };
type B = { x: string } & { y: string };직관적으로 보면 A와 B는 완전히 같은 구조를 가진 타입이다. 두 타입 모두 x와 y라는 문자열 프로퍼티를 갖고 있고, 실사용 관점에서도 서로를 대체해 사용할 수 있다. 하지만 TypeScript는 이 두 타입을 완전히 동일한 타입으로 간주하지 않는다.
이를 확인하기 위해, 두 타입이 동일한지를 판별하는 유틸리티 타입 IsEqual을 만들어보자.
type IsEqual<X, Y> =
(<T>() => T extends X ? 1 : 2) extends
(<T>() => T extends Y ? 1 : 2)
? true
: false;이제 A와 B를 비교해보면 다음과 같은 결과가 나온다.
type Result = IsEqual<A, B>; // false겉보기에는 true가 나올 것 같지만, 실제 결과는 false다. 즉, TypeScript는 { x: string; y: string }과 { x: string } & { y: string }을 서로 다른 타입으로 판단하고 있다.
이 차이는 타입의 “구조”가 아니라, 타입이 만들어진 방식에서 비롯된다. A는 하나의 명시적인 객체 타입으로 선언된 반면, B는 두 객체 타입을 인터섹션을 통해 결합한 타입이다. TypeScript는 이 둘을 내부적으로 동일한 형태로 정규화하지 않으며, 그 결과 타입 비교 단계에서는 서로 다른 타입으로 취급된다.
흥미로운 점은, 두 타입이 서로 완전히 호환 가능하다는 사실이다. 양방향으로 extends가 성립한다는 것은, 실질적인 사용 관점에서는 두 타입이 동일하게 동작한다는 의미다. 그럼에도 불구하고, IsEqual<A, B>는 여전히 false를 반환한다.
type K = A extends B ? true : false; // true
type L = B extends A ? true : false; // true이 결과는 TypeScript에서 타입 호환성과 타입 동일성은 서로 다른 개념임을 분명하게 보여준다. 구조적 타입 시스템을 사용한다고 해서, 구조가 같으면 언제나 동일한 타입으로 평가되는 것은 아니다.
이전까지 나는 두 타입이 상호 호환 가능하다면, 그 둘은 사실상 “같은 타입”이라고 생각해왔다. 하지만 TypeScript의 타입 시스템에서는 그 생각이 항상 옳지 않다. 타입은 단순히 어떤 형태를 가지는지가 아니라, 어떤 과정을 통해 정의되었는지에 따라서도 다르게 취급될 수 있다.
이 차이는 보통 일상적인 타입 사용에서는 크게 문제되지 않는다. 하지만 타입 동일성을 기준으로 분기하는 유틸리티 타입을 작성하거나, 라이브러리 레벨에서 타입을 조합하고 비교하는 경우에는 예상치 못한 결과를 만들어낼 수 있다. 특히 인터섹션 타입을 적극적으로 사용하는 코드베이스라면, 이 미묘한 차이를 인지하고 있어야 한다.
다행히 이 문제를 완화하는 방법은 있다. 이전에 소개했던 Roll 유틸리티 타입을 사용하면, 인터섹션 타입을 명시적인 객체 타입으로 한 번 정규화할 수 있다.
type Result = IsEqual<A, Roll<B>>; // trueRoll<B>는 인터섹션으로 구성된 타입을 하나의 객체 타입처럼 재평가하기 때문에, A와의 비교에서 true를 반환하게 된다. 이를 통해 타입의 “형태”뿐만 아니라, 비교 가능한 동일성까지 맞추는 것이 가능해진다.
결국 이 사례가 보여주는 핵심은 명확하다. TypeScript에서 타입을 다룰 때는 “쓸 수 있는가”와 “같은가”를 구분해야 한다. 상호 호환성만으로 타입의 동일성을 판단하는 것은, 특히 타입 레벨 로직을 작성하는 상황에서는 충분하지 않을 수 있다.
더 읽어보기
2025.09.14
Loose Autocomplete
프로그래밍을 하다 보면 개발자가 의도한 선택지를 에디터가 자동완성으로 얼마나 잘 안내해 주느냐가 개발 경험에 큰 영향을 미친다는 사실을 자주 체감하게 된다. 특히 문자열 기반의 옵션을 다룰 때는 이 차이가 더욱 극명하게 드러난다. 예를 들어 HTTP 메서드를 표현할 때 "GET" | "…
2025.04.25
더 좁은 타입의 유효성에 대하여
사람이 무언가를 집중해서 바라보다 보면, 어느샌가 주변부가 흐려지고 가끔은 집중하고 있던 그 대상조차 보이지 않게 된다. 처음엔 분명하게 인식되던 경계가 서서히 사라지고, 오히려 애써 무시했던 주변이 본질을 가릴 때도 있다. 잘 보려 애쓰는 행위가, 역설적으로 시야를 좁히는 순간이다.…
2025.04.06
재귀 조건부 타입에서의 추론 컨텍스트 손실
타입스크립트에서는 조건부 타입의 분배 과정에서도 타입 추론 컨텍스트가 유지된다. 이 특성 덕분에 단순한 분기 수준을 넘어, 상당히 복잡한 조건부 타입에서도 개발자가 의도한 방향으로 타입 추론을 유도할 수 있다. 실제로 이러한 특성은 고급 유틸리티 타입을 설계할 때 매우 강력한 도구로 작…
2025.03.28
유틸리티 타입 IsInRange
프론트엔드 개발을 하다 보면 컴포넌트의 props로 number 타입을 받을 때가 많다. 하지만 단순히 number 타입만 지정하면 값의 범위를 제한할 수 없다는 점이 아쉽다. 예를 들어, 페이지네이션의 currentPage는 1 이상의 값만 허용해야 하고, 슬라이더의 value는 최소…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
2026.05.21
7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다…
댓글
댓글을 불러오는 중...