TypeScript의 판별된 union을 이용한 state와 API response 처리
1. 개요
많은 UI 버그는 일어날 수 없어야 하는 state에서 발생합니다. 예를 들어 isLoading이 true인데 error도 값을 가지고 있거나, 요청이 성공했다고 표시되었는데 data가 undefined인 경우입니다.
이 글에서는 TypeScript의 discriminated union을 사용하여 데이터 로딩 state와 API response를 모델링하는 방법을 안내합니다. 이를 통해 다음을 배우게 됩니다:
- 판별 필드를 가진 union 정의하기,
- 컴파일러가 자동으로 타입 좁히기(narrowing)를 수행하도록 하기,
- exhaustiveness check로 모든 분기를 반드시 처리하도록 강제하기,
- Zod를 사용한 런타임 response 검증,
- state 변환에 대한 테스트 작성.
2. “선택적 필드 모음”의 문제점
일반적인 방법은 모든 것을 선택적 필드가 있는 하나의 interface로 묶는 것입니다:
interface OrderState {
isLoading: boolean;
error?: string;
order?: { id: string; total: number };
}
const state: OrderState = {
isLoading: true,
error: "Timeout",
order: { id: "A1", total: 120 },
};
위의 코드는 정상적으로 컴파일됩니다. 하지만 이는 무의미한 state를 설명합니다: 동시에 로딩 중이면서, 에러가 있으면서, 데이터도 있다는 뜻입니다.
세 개의 필드가 있으면 2 × 2 × 2 = 8가지 조합이 가능합니다. 실제로는 약 4가지 조합만 유효합니다. State를 읽을 때도 state.order?.total 또는 state.order!을 곳곳에 써야 합니다. 에러가 발생하면 컴파일러가 도와줄 수 없습니다.
3. Discriminated union이란
Discriminated union은 여러 object 타입의 union입니다. 각 타입은 literal type을 가진 공통 필드를 하나씩 갖습니다. 이 필드를 discriminant 또는 “tag”라고 부릅니다:
type Order = { id: string; total: number };
type OrderState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; order: Order }
| { status: "error"; message: string };
여기서 status가 discriminant입니다. 각 분기는 자신에게만 의미가 있는 필드를 선언합니다. success 분기는 order를 가지고, error 분기는 message를 가집니다. “로딩 중이면서 에러도 있는” 상태는 더 이상 표현할 수 없습니다:
const bad: OrderState = { status: "loading", message: "Timeout" };
error TS2353: Object literal may only specify known properties,
and 'message' does not exist in type '{ status: "loading"; }'.
3.1. discriminant에 따른 타입 좁히기
discriminant의 값을 확인하면 TypeScript이 해당 분기에서 자동으로 타입을 좁혀줍니다:
function describe(state: OrderState): string {
if (state.status === "success") {
return `주문 ${state.order.id}: ${state.order.total}원`;
}
if (state.status === "error") {
return `에러: ${state.message}`;
}
return state.status === "loading" ? "로딩 중..." : "시작하지 않음";
}
console.log(describe({ status: "success", order: { id: "A1", total: 120 } }));
console.log(describe({ status: "error", message: "Timeout" }));
주문 A1: 120원
에러: Timeout
if (state.status === "success") 분기 안에서 state의 타입은 { status: "success"; order: Order }입니다. 따라서 state.order.id에 바로 접근할 수 있고, ?.나 !를 쓸 필요가 없습니다. 이 분기에서 state.message에 접근하려 하면 컴파일러가 에러를 냅니다.
4. Exhaustiveness check: 상태를 빠트리지 않기
Union의 진정한 가치는 새 분기를 추가했을 때 컴파일러가 아직 처리되지 않은 곳을 모두 지적할 수 있다는 것입니다. 흔한 방법은 switch와 never 타입을 함께 사용하는 것입니다:
function assertNever(value: never): never {
throw new Error(`처리되지 않은 상태: ${JSON.stringify(value)}`);
}
function render(state: OrderState): string {
switch (state.status) {
case "idle":
return "<button>주문 로드</button>";
case "loading":
return "<spinner />";
case "success":
return `<p>총액: ${state.order.total}원</p>`;
case "error":
return `<p class="error">${state.message}</p>`;
default:
return assertNever(state);
}
}
네 개의 case를 모두 처리한 후 default 분기에서 state의 타입은 never입니다. 따라서 assertNever(state) 호출은 유효합니다.
나중에 OrderState에 { status: "cancelled" } 분기를 추가했지만 render를 업데이트하지 않았다고 가정하면:
error TS2345: Argument of type '{ status: "cancelled"; }'
is not assignable to parameter of type 'never'.
빌드 시점에 에러가 나타나므로 사용자가 빈 화면을 보게 되는 일을 막을 수 있습니다. 런타임에 신뢰할 수 없는 API 같은 곳에서 예상 못 한 데이터가 들어오면, assertNever이 명확한 에러를 던져줍니다.
주의:
"strict": true를 설정하고 명시적 반환 타입(: string)을 선언했다면 하나의case를 빠뜨려도 “Function lacks ending return statement” 에러가 발생합니다. 하지만assertNever을 사용하는 것이 좋습니다. 더 명확한 에러 메시지를 제공하고void를 반환하는 함수에서도 동작합니다.
5. API response 모델링
API는 보통 두 가지 형태로 응답합니다: 성공 또는 실패. 여러 곳에서 재사용할 수 있는 Result 타입을 정의할 수 있습니다:
type Result<T, E = ApiError> =
| { ok: true; data: T }
| { ok: false; error: E };
type ApiError =
| { kind: "network"; cause: string }
| { kind: "http"; status: number }
| { kind: "invalid"; issues: string[] };
discriminant가 반드시 문자열일 필요는 없습니다. ok: true와 ok: false는 boolean의 서로 다른 literal type이므로 타입 좁히기가 여전히 작동합니다. ApiError도 내부의 discriminated union으로, kind를 통해 구분됩니다.
5.1. Zod를 사용한 런타임 response 검증
TypeScript 타입은 컴파일 후 사라집니다. (await res.json()) as Order로 쓰면 컴파일러에 “약속”하는 것일 뿐, 실제로는 아무것도 검증하지 않습니다. 서버에서 온 데이터가 올바른 형태인지 확인하려면 Zod로 검증하세요:
npm install zod
서버가 type이라는 discriminant를 가진 payload를 반환한다고 가정하면:
import { z } from "zod";
const OrderSchema = z.object({ id: z.string(), total: z.number() });
const OrderResponseSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("found"), order: OrderSchema }),
z.object({ type: z.literal("not_found"), orderId: z.string() }),
]);
type OrderResponse = z.infer<typeof OrderResponseSchema>;
z.discriminatedUnion("type", [...]) 는 type 필드를 기반으로 한 schema를 생성합니다. Zod는 먼저 type 값을 읽고 그에 맞는 분기로 검증하므로 z.union보다 더 정확한 에러 메시지를 제공합니다. z.infer는 해당하는 TypeScript 타입을 생성하므로 타입을 두 번 정의할 필요가 없습니다.
다음은 API를 호출하는 함수이며, 모든 에러를 Result로 감싸줍니다:
async function fetchOrder(id: string): Promise<Result<OrderResponse>> {
let res: Response;
try {
res = await fetch(`/api/orders/${id}`);
} catch (e) {
return { ok: false, error: { kind: "network", cause: String(e) } };
}
if (!res.ok) {
return { ok: false, error: { kind: "http", status: res.status } };
}
const parsed = OrderResponseSchema.safeParse(await res.json());
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
return { ok: false, error: { kind: "invalid", issues } };
}
return { ok: true, data: parsed.data };
}
이 함수는 절대 에러를 던지지 않습니다. 모든 상황이 반환 타입으로 표현됩니다. safeParse 자체도 discriminant가 success인 discriminated union을 반환합니다. 이것이 바로 당신이 배우는 패턴입니다.
함수를 호출하는 쪽에서 결과를 처리합니다:
async function showOrder(id: string): Promise<string> {
const result = await fetchOrder(id);
if (!result.ok) {
switch (result.error.kind) {
case "network": return "연결 끊김, 나중에 다시 시도하세요.";
case "http": return `서버가 ${result.error.status}를 반환했습니다.`;
case "invalid": return "데이터가 유효하지 않습니다.";
default: return assertNever(result.error);
}
}
return result.data.type === "found"
? `총액: ${result.data.order.total}원`
: `주문 ${result.data.orderId}를 찾을 수 없습니다`;
}
if (!result.ok) { ... } 분기 다음에서 TypeScript는 result가 확실히 ok: true 분기라는 것을 알고 있습니다. 따라서 result.data에 바로 접근할 수 있습니다.
6. Reducer를 통한 state 변환
Discriminated union은 reducer와 매우 잘 맞습니다. React의 useReducer든 직접 작성한 store든 state와 action 모두 union입니다:
type OrderAction =
| { type: "FETCH" }
| { type: "RESOLVE"; order: Order }
| { type: "REJECT"; message: string }
| { type: "RESET" };
function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case "FETCH":
return state.status === "loading" ? state : { status: "loading" };
case "RESOLVE":
return state.status === "loading"
? { status: "success", order: action.order }
: state;
case "REJECT":
return state.status === "loading"
? { status: "error", message: action.message }
: state;
case "RESET":
return { status: "idle" };
default:
return assertNever(action);
}
}
이 reducer는 state 변환 규칙까지 인코딩합니다. RESOLVE는 loading 상태일 때만 효과가 있습니다. 덕분에 사용자가 RESET한 후에 늦게 도착한 response는 무시됩니다. 추가 flag가 필요 없습니다.
7. 핵심 동작을 테스트하기
이 예제는 Vitest를 사용합니다. 이 라이브러리의 expect 문법은 AssertJ나 Jest와 유사합니다:
npm install -D vitest
import { describe, it, expect } from "vitest";
import { orderReducer, render } from "./order";
describe("orderReducer", () => {
const order = { id: "A1", total: 120 };
it("RESOLVE일 때 loading에서 success로 변환", () => {
const next = orderReducer({ status: "loading" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "success", order });
});
it("idle 상태에서 늦은 RESOLVE 무시", () => {
const next = orderReducer({ status: "idle" }, { type: "RESOLVE", order });
expect(next).toEqual({ status: "idle" });
});
it("render가 에러 메시지 표시", () => {
expect(render({ status: "error", message: "Timeout" }))
.toBe('<p class="error">Timeout</p>');
});
});
npx vitest run
✓ order.test.ts (3 tests)
✓ orderReducer > RESOLVE일 때 loading에서 success로 변환
✓ orderReducer > idle 상태에서 늦은 RESOLVE 무시
✓ orderReducer > render가 에러 메시지 표시
Test Files 1 passed (1)
Tests 3 passed (3)
두 번째 테스트가 가장 중요합니다. 이 테스트는 race condition에 대한 방어 동작을 고정합니다. 이는 “선택적 필드 모음” 모델에서 매우 흔히 발생하는 버그입니다.
8. 흔한 실수와 사용하면 안 되는 경우
discriminant가 string으로 “확대”되기. 중간 변수에서 object를 만들면 TypeScript가 literal 대신 string 타입을 추론할 수 있습니다:
const s = { status: "loading" };
const state: OrderState = s;
error TS2322: Type '{ status: string; }' is not assignable to type 'OrderState'.
세 가지 방법으로 고칠 수 있습니다: 처음부터 타입을 선언하거나 (const s: OrderState = ...), as const를 쓰거나, satisfies OrderState를 사용하세요.
조기 구조분해가 연결을 끊기. let { status, ...rest } = state라고 쓰면 TypeScript는 status를 확인할 때 rest가 어느 분기에 속하는지 알 수 없습니다. 구조분해를 통한 타입 좁히기는 변수가 const로 선언되고 재할당되지 않을 때만 작동합니다.
discriminant 대신 in 연산자 사용. "order" in state 확인도 타입 좁히기를 합니다. 하지만 두 분기가 같은 이름의 필드를 가지면 깨집니다. 항상 discriminant를 통해 확인하세요.
discriminant가 선택적이거나 변경 가능. status?: ...로 선언하지 마세요. 또한 기존 object에서 state.status = "error"를 할당하지도 마세요. 대신 각 state마다 새 object를 생성하세요. 필드를 readonly로 표시하면 컴파일러가 이 규칙을 지키도록 강제할 수 있습니다.
검증 대신 타입 강제. 네트워크에서 온 데이터에 as OrderResponse를 쓰면 위의 모든 이점을 무효화합니다. 시스템의 경계에서는 항상 런타임에 데이터를 검증하세요.
주의: 필드들이 실제로 독립적일 때는 discriminated union을 사용하지 마세요. 예를 들어 form의
email?,phone?,note?는 어떤 조합으로든 채워질 수 있습니다. 이 경우 선택적 필드가 올바른 모델입니다. 이들을 union으로 강제하면 분기 수만 늘어나고 추가 안전성은 없습니다.
9. 결론
Discriminated union은 유효하지 않은 state를 컴파일 에러로 만들어줍니다. 다음을 배웠습니다:
- literal discriminant를 가진 union 정의,
- TypeScript가 discriminant에 따라 자동 타입 좁히기,
assertNever로 모든 분기 처리 강제,Result와z.discriminatedUnion으로 API response 모델링 및 데이터 검증,- reducer와 테스트에 같은 패턴 적용.
핵심 규칙은: 필드들이 항상 함께 의미가 있다면 union의 같은 분기에 넣으세요. 다음번에 isLoading을 error?와 data? 옆에 쓰려고 하면, 대신 status 필드로 바꾸는 것을 고려해보세요.