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
나쁜 React API 의 절반이 '가끔 이들 같이, 절대 저들은 안 됨' 을 조용히 암시하는 optional prop 에서 와. Discriminated union 이 그 규칙을 명시적으로 만들고 컴파일러가 강제하게 해.

모양

Discriminated union 은 object 타입의 union — 모든 variant 가 단일 literal 필드 (discriminant) 를 다른 값으로 공유. TypeScript 가 discriminant 로 union narrow — 코드에서 discriminant 체크하면 컴파일러가 어떤 variant 인지 앎.

전형적인 Button-or-Link 문제

한 컴포넌트가 onClick 넘기면 스타일 잡힌 button, href 넘기면 스타일 잡힌 anchor 렌더 원함. Optional prop 으로 하면 { onClick?: () => void; href?: string } — 둘 다 넘겨도 (모호) 또는 둘 다 안 넘겨도 (쓸모없음) 컴파일됨. Discriminated-union 버전은 계약을 배타적 으로: 정확히 한 variant 골라야.

컴포넌트 안에서 narrowing

Discriminant 체크하면 (if (props.variant === 'link')) TypeScript 가 타입 narrow — 그 블록 안에선 href 가 required, onClick 은 존재 안 함. 타입 assertion 없음, ! 없음. 컴파일러가 union 을 대신 walk.

스케일이 되는 자리

Button/Link 너머, discriminated union 은 어디나:

  • Fetch 결과: { status: 'loading' } | { status: 'error'; error: Error } | { status: 'success'; data: T }.
  • Form state: { phase: 'editing'; draft: ... } | { phase: 'submitting' } | { phase: 'saved'; saved: ... }.
  • Cinder 의 protocol envelope: { type: 'preview.captured'; payload: ... } | { type: 'generation.candidate_ready'; payload: ... } — cwkCinder 의 packages/protocol 이 정확히 쓸 모양.
불가능한 상태를 불가능하게. { loading: true, error: 'oops' } 를 허용하는 타입은 결국 그 버그 만들어내는 타입. Discriminated union 이 타입 시스템에 손 뻗어 버그 쓰여지기 전에 제거하는 방식.

Code

Button-or-Link — 잘못된 방식과 옳은 방식·tsx
// WRONG — optional prop 이 호출자에게 둘 다, 둘 다 안, 모순 쌍 허용.
type BadProps = {
  onClick?: () => void;
  href?: string;
  children: React.ReactNode;
};
// <Bad /> — 동작 없음, 그래도 컴파일.
// <Bad onClick={f} href="/x" /> — 둘 다, 모호, 그래도 컴파일.

// RIGHT — 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") {
    // Narrowed: TS 가 href 있고 onClick 없음을 앎.
    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 state — 불가능한 상태 막는 audit-gap-grade 타입·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

세 variant — success, warning, error — 가진 Toast 컴포넌트를 discriminated union 으로 모델링. success variant 는 message: string 필요. warningmessage: string + onDismiss: () => void. errormessage: string + error: Error + onRetry: () => void. 각 variant 를 적절한 Tailwind 색으로 렌더. 한 variant 에서 required prop 일부러 빠뜨려서 컴파일러가 플래그하는지 확인.
Hint
type ToastProps = { kind: 'success'; ... } | { kind: 'warning'; ... } | { kind: 'error'; ... }. 그 후 if (props.kind === 'error') 가 narrow. 컴파일러가 variant-specific prop 빠진 호출 거부.

Progress

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

댓글 0

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

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