본문 바로가기
C.W.K.
Stream
Lesson 05 of 07 · published

Discriminated Union — Prop이 상호 배타일 때

~14 min · typescript, discriminated-union, narrowing

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
optional prop만 늘어놓으면 허용되는 조합과 금지되는 조합이 흐려져. discriminated union으로 그 규칙을 타입에 새겨 보자.

기본 모양

Discriminated union은 여러 객체 타입이 variant, kind, status 같은 공통 필드를 서로 다른 리터럴 값으로 공유하는 union이야. TypeScript는 그 필드를 검사한 뒤 현재 객체가 어느 타입인지 좁혀 줘.

Button과 Link를 하나의 계약으로

하나의 컴포넌트가 button 또는 link가 되어야 한다고 해 보자. 모든 prop을 optional로 만들면 href와 onClick을 둘 다 넘기거나 둘 다 빼도 컴파일돼. 대신 variant: 'button'에는 onClick을, variant: 'link'에는 href를 요구하는 두 타입을 union으로 묶으면 정확히 한 모양만 고를 수 있어.

컴포넌트 안에서 narrowing하기

props.variant === 'link'를 확인한 블록에서는 href가 필수이고 onClick은 없는 타입으로 좁혀져. 타입 assertion 없이도 컴파일러가 올바른 prop을 알려 줘.

어디에 쓰나

이 패턴은 UI variant뿐 아니라 fetch의 loading·error·success 상태에도 잘 맞아. Form의 idle·submitting·success·error와 cwkCinder의 request·response·오류 규약 봉투도 같은 방식으로 표현할 수 있어.

불가능한 상태는 타입에서도 만들 수 없게 해. 서로 충돌하는 boolean과 optional prop을 허용하면 언젠가 그 조합이 실제 버그가 돼. Discriminated union은 잘못된 조합을 실행 전에 막아 줘.

불가능한 prop 조합을 타입에서 없애

Button이 link와 button 두 역할을 맡는다면 variant: 'link'일 때는 href를 요구하고 onClick 전용 계약은 막을 수 있어. variant: 'button'일 때는 type과 onClick을 허용하고 href는 거부해. 모든 필드를 optional로 둔 한 객체보다 호출 지점의 실수를 훨씬 일찍 잡아.

Switch의 빠진 분기를 검사해

각 case에서 discriminant를 확인하면 TypeScript가 해당 variant의 필드만 남겨 줘. 마지막 default에서 값을 never에 할당하면 새 variant를 추가하고 렌더링 분기를 잊었을 때 컴파일 오류가 나.

UI 밖의 상태에도 같은 구조를 써

Fetch는 loading·error·success, form은 idle·submitting·success·error로 표현할 수 있어. cwkCinder의 request·response·오류 규약 봉투처럼 단계마다 필요한 데이터가 다른 메시지 계약에도 잘 맞아.

Code

Button과 Link union의 잘못된 방식과 올바른 방식·tsx
// 잘못된 예: optional prop만 쓰면 서로 모순된 조합도 허용돼.
type BadProps = {
  onClick?: () => void;
  href?: string;
  children: React.ReactNode;
};
// <Bad /> — 동작 없음, 그래도 컴파일.
// <Bad onClick={f} href="/x" /> — 둘 다, 모호, 그래도 컴파일.

// 올바른 예: discriminated union은 정확히 한 variant만 허용해.
type ButtonProps =
  | { variant: "button"; onClick: () => void; children: React.ReactNode }
  | { variant: "link"; href: string; children: React.ReactNode };

export function Button(props: ButtonProps) {
  const base = "inline-flex px-4 py-2 rounded-lg font-medium";
  if (props.variant === "link") {
    // 이 분기에서는 TypeScript가 href만 있음을 알아.
    return <a href={props.href} className={base}>{props.children}</a>;
  }
  return (
    <button onClick={props.onClick} className={base}>
      {props.children}
    </button>
  );
}

// <Button variant="link" href="/about">About</Button>          ✓
// <Button variant="button" onClick={save}>Save</Button>        ✓
// <Button variant="button">Save</Button>                       ✗ onClick 빠짐
// <Button variant="link" onClick={save}>Save</Button>          ✗ link는 onClick 안 받음
불가능한 조합을 막는 fetch 상태 타입·tsx
type FetchResult<T> =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: T };

function renderResult<T>(
  result: FetchResult<T>,
  renderData: (data: T) => React.ReactNode
): React.ReactNode {
  switch (result.status) {
    case "loading": return <Spinner />;
    case "error":   return <p className="text-red-500">{result.error.message}</p>;
    case "success": return renderData(result.data);
  }
}

// '!' assertion 없음. 'if (loading && error)' 모순 없음. 컴파일러가
// 모든 branch 도달 가능 + 다른 건 도달 불가 보장.

External links

Exercise

success, warning, error 세 variant로 이루어진 Toast prop을 discriminated union으로 모델링해. 모두 message가 필요하고 warning에는 onDismiss, error에는 Error 객체와 onRetry가 추가되게 만들어. 각 variant를 다른 색으로 렌더한 뒤 필수 prop 하나를 빼 TypeScript가 호출을 거부하는지 확인해.
Hint
props.kind를 switch하면 각 case에서 해당 variant의 prop만 사용할 수 있어. 마지막 분기에 never 검사를 더해 새 variant도 놓치지 않게 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.