판별 유니온으로 "불가능한 상태"를 타입에서 없애기
isLoading·data·error를 각각 boolean으로 쪼갰더니, 로딩인데 데이터도 에러도 있는 "불가능한 조합"이 타입상 합법이 됐다. as나 ?.를 연달아 쓰게 된다면 타입이 아니라 상태 모델링이 덜 된 신호다.
React에서 데이터 로딩 UI를 이렇게 짜본 적, 다들 있으실 거예요.
type State = { isLoading: boolean; data?: User[]; error?: string };
평범해 보이죠.
그런데 여기 화면 버그의 온상이 숨어 있어요.
isLoading·data·error가 서로 독립된 세 필드라, 타입상 허용되는 조합이 2×2×2 = 8가지예요.
그중엔 로딩 중인데 data도 error도 있는 상태, 로딩 끝났고 에러도 없는데 data는 아직 undefined인 상태도 있어요.
두 번째가 특히 무서워요.
function List({ isLoading, data, error }: State) {
if (isLoading) return <Skeleton />;
if (error) return <Error msg={error} />;
return <ul>{data.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
// ^^^^ data가 undefined면 여기서 터진다
}
컴파일은 통과하는데 실행하면 빈 응답이나 초기 렌더에서 undefined.map이 터져요.
타입은 왜 이걸 못 막았을까요.
TypeScript 입장에선 “isLoading이 false면 data가 있다”는 관계를 알 방법이 없거든요.
세 필드가 남남이라 data는 끝까지 User[] | undefined로 남아요.
해결은 상태를 하나의 status 필드로 묶는 거예요.
type State =
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
status처럼 리터럴 값을 가진 공통 필드를 판별자(discriminant)라고 해요.
이게 있으면 switch/if만으로 TypeScript가 알아서 나머지 필드를 열어줘요.
function render(s: State) {
switch (s.status) {
case 'loading': return <Spinner />;
case 'success': return <p>{s.data.length}개</p>; // data가 옵셔널이 아님! ?. 불필요
case 'error': return <p>{s.error}</p>;
default: {
const _never: never = s; // 소진 검사: case 빠지면 컴파일 에러
return null;
}
}
}
이제 “로딩인데 data가 있는” 조합은 타입에 존재조차 하지 않아요.
이걸 make impossible states impossible, 불가능한 상태를 불가능하게 만든다고 불러요.
퍼블리셔의 언어로 옮기면 .btn.is-loading이에요.
상태 클래스가 붙어야 안에 스피너 마크업이 있다고 확신할 수 있잖아요.
클래스가 곧 판별자인 거죠.
default에 넣은 const _never: never = s 한 줄엔 선물이 있어요.
나중에 status: 'idle'을 하나 추가하면, 처리 안 한 이 자리에서 컴파일 에러가 나요.
상태를 늘렸을 때 고쳐야 할 곳을 컴파일러가 리뷰어보다 먼저 찾아주는 거예요.
감각 하나만 챙기면 돼요.
as나 ?.를 연달아 쓰고 있다면, 타입이 틀린 게 아니라 상태 모델링이 덜 된 거예요.
(user as Member).email // TS에게 "그냥 믿어"라고 우기는 것 — Guest가 오면 런타임 폭발
as는 검사를 끄는 스위치예요.
대신 코드로 조건을 확인해서 좁혀요.
if (user.role === 'member') { user.email } // role이 판별자 → 안전하게 열림
if (typeof v === 'string') { /* v: string */ }
if (err instanceof Error) { /* err: Error */ }
if ('email' in user) { /* email 가진 타입 */ }
TanStack Query의 응답, Server Actions 반환값, ShadCN의 variant props가 전부 이 패턴이에요.
알면 남의 타입 읽는 속도가 붙고, never 소진 검사를 걸어두면 상태를 늘려도 안전하고요.
상태를 boolean 여러 개로 쪼개고 싶어질 때 한 번 멈춰서 물어보세요.
이것들이 동시에 참일 수 있나.
아니라면 판별 유니온으로 묶어서 불가능한 조합 자체를 없애는 게 나아요.
댓글