TypeScript 유니온 타입에서 필드가 존재하는지 매번 옵셔널 체이닝한 이유
문제 발생
요청 상태(로딩 중/성공/실패)를 하나의 타입으로 표현했는데, 어디서든 data나 error에 접근하기 전에 항상 ?.(옵셔널 체이닝)과 null 체크를 반복해야 했습니다.
interface RequestState {
status: "idle" | "loading" | "success" | "error";
data?: User;
error?: string;
}
function render(state: RequestState) {
if (state.status === "success") {
console.log(state.data.name); // 에러: data는 여전히 undefined일 수 있음
}
}원인 분석
위 인터페이스는 status가 "success"여도 TypeScript 입장에서는 data가 User | undefined라는 사실이 전혀 바뀌지 않습니다. status와 data/error가 서로 독립된 필드로 선언되어 있어서, 컴파일러는 "status가 "success"면 data가 반드시 존재한다"는 실제 비즈니스 규칙을 전혀 알지 못합니다. 그래서 매번 옵셔널 체이닝이나 non-null assertion(!)으로 우회해야 했던 것입니다.
해결 방안
**식별 유니온(discriminated union)**으로 상태별로 실제로 존재하는 필드가 다르다는 것을 타입 시스템에 직접 표현합니다. 공통 필드(status, "판별자"라고 부릅니다)의 값에 따라 나머지 필드 구성이 달라지는 여러 개의 타입을 유니온으로 묶습니다.
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User } // success일 때만 data 존재
| { status: "error"; error: string }; // error일 때만 error 존재
function render(state: RequestState) {
if (state.status === "success") {
console.log(state.data.name); // 에러 없음 — data가 확실히 존재함을 컴파일러가 앎
}
}if (state.status === "success")로 분기하는 순간, TypeScript는 자동으로 state의 타입을 { status: "success"; data: User }로 좁혀줍니다(narrowing) — 이제 data가 옵셔널이 아니라 반드시 존재하는 필드라는 것을 컴파일러가 보장합니다.
- "이 상태일 때만 이 필드가 존재한다"는 규칙이 있다면 옵셔널 필드로 어설프게 표현하지 말고 식별 유니온으로 명시적으로 나눕니다.
switch (state.status)문과 함께 쓰면 모든 케이스를 다뤘는지 컴파일러가 검사해주는 것(exhaustiveness checking)도 활용할 수 있습니다 —default분기에서never타입 체크를 넣어두면, 나중에 상태가 추가됐는데 분기 처리를 빠뜨렸을 때 컴파일 에러로 바로 알 수 있습니다.- API 응답, 폼 상태, Redux 액션 타입처럼 "여러 변형이 있는 데이터"를 다룰 때 이 패턴이 특히 유용합니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.