isLoading が true なのに data も存在している。そんなバグに遭遇したこと、ありませんか?
optional props を使っていると、本来ありえない状態の組み合わせが許容されてしまいます。この記事では、optional props と discriminated union(判別共用体)の使い分けについて、実務での判断基準を整理します。
Props Soup vs Compound Component がコンポーネントの「構造」の話だったのに対し、今回は「状態の型」の話です。
問題:optional props が許す「ありえない状態」
典型的な非同期データ取得コンポーネントを考えてみましょう。
// ❌ optional props パターン
type DataDisplayProps = {
isLoading?: boolean
error?: Error
data?: User[]
}
function DataDisplay({ isLoading, error, data }: DataDisplayProps) {
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
if (data) return <UserList users={data} />
return null
}
一見問題なさそうですが、この型は以下の状態をすべて許容してしまいます。
// これらがすべて型エラーなしで通る
<DataDisplay isLoading={true} data={users} /> // ローディング中なのにデータある?
<DataDisplay isLoading={true} error={err} /> // ローディング中なのにエラー?
<DataDisplay error={err} data={users} /> // エラーなのにデータある?
<DataDisplay isLoading={true} error={err} data={users} /> // 全部ある?
実際にこのバグ、本番で見たことあります。「なんでローディング中なのにデータ表示されてるの?」って。
developerway.com では、この問題を「型システムがそれを止めない。結果として、顧客向けUIがめちゃくちゃになる」と表現しています。
解決策:discriminated union で状態を排他的に
discriminated union(判別共用体)を使えば、相互排他的な状態を型レベルで表現できます。
// ✅ discriminated union パターン
type DataDisplayProps =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: User[] }
function DataDisplay(props: DataDisplayProps) {
switch (props.status) {
case 'idle':
return null
case 'loading':
return <Spinner />
case 'error':
return <ErrorMessage error={props.error} />
case 'success':
return <UserList users={props.data} />
}
}
これで loading 状態のときに data や error を渡そうとすると、コンパイル時にエラーになります。
// ✅ 型エラー!status が 'loading' のときに data は存在しない
<DataDisplay status="loading" data={users} />
Total TypeScript のワークショップ では、このパターンを「optional の曖昧さを排除し、要件をバリアントごとに明示する」と説明しています。
判断基準:どちらを使うべきか
discriminated union を選ぶべきケース
1. 同じ場所・同じデザインで、文章だけ違う
これが一番シンプルな判断基準です。同じ場所に表示される、デザインも同じ、でもステータスによって表示内容が変わる。そういうコンポーネントはバラバラに作らず、discriminated union で1つにまとめるべきです。
// 通知バナー:表示場所もデザインも同じ、文章だけ違う
type NotificationProps =
| { type: 'success'; message: string }
| { type: 'warning'; message: string; action?: () => void }
| { type: 'error'; message: string; retryable: boolean }
「これ、SuccessNotification と WarningNotification と ErrorNotification に分けるべき?」と迷ったら、同じ文脈かどうかで判断してください。同じ場所で使われて、同じようなデザインなら、1つのコンポーネントにまとめた方がシンプルです。
2. 状態が相互排他的
loading / success / error は同時に起きません。pending / approved / rejected も同様。このような「どれか1つだけ」の状態には discriminated union が最適です。
3. 状態ごとに必要な props が違う
error 状態のときだけ error オブジェクトが必要、success のときだけ data が必要。こういった「状態に紐づく追加情報」がある場合は discriminated union の出番です。
4. 不正な組み合わせがバグを生む
andrewbran.ch の記事では、「型システムは単にバグを早期発見するだけでなく、開発者を正しいパターンに導くべき」と述べられています。不正な状態がUIのバグにつながるなら、型で防ぐ価値があります。
optional props で十分なケース
1. 各 props が独立している
type ButtonProps = {
bordered?: boolean
hoverable?: boolean
disabled?: boolean
}
これらは独立したフラグです。bordered と hoverable が同時に true でも問題ありません。むしろ、すべての組み合わせが有効です。
2. 組み合わせに制約がない
4つの boolean があれば 16 通りの組み合わせがありますが、すべてが valid ならわざわざ union にする必要はありません。
3. シンプルな「あるかないか」の情報
type CardProps = {
title: string
subtitle?: string // あってもなくてもいい
footer?: ReactNode // あってもなくてもいい
}
実装上の注意点
destructuring すると narrowing が効かない
discriminated union を使うときの最大の落とし穴がこれです。
// ❌ destructuring すると narrowing が効かない
function DataDisplay({ status, data, error }: DataDisplayProps) {
if (status === 'success') {
// data は undefined | User[] のまま
// TypeScript は narrowing できない
return <UserList users={data} /> // エラー!
}
}
これ、本当に2時間くらいハマりました。「なんで narrowing 効かないの?」ってずっと悩んでた。
なぜ効かないのか?
destructuring した瞬間、TypeScript は各変数の型を個別に決定します。
type DataDisplayProps =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: Error }
| { status: 'success'; data: User[] }
function DataDisplay({ status, data, error }: DataDisplayProps) {
// この時点で各変数の型は:
// status: 'idle' | 'loading' | 'error' | 'success'
// data: User[] | undefined (success にしか存在しないから)
// error: Error | undefined (error にしか存在しないから)
}
| 変数 | 型 | 理由 |
|---|---|---|
status | 'idle' | 'loading' | 'error' | 'success' | 全バリアントに存在 |
data | User[] | undefined | success にしか存在しない |
error | Error | undefined | error にしか存在しない |
この時点で3つの変数は完全に独立。元の union 型との関連性は失われています。
if (status === 'success') {
// status は 'success' に narrow される ✅
// でも data は User[] | undefined のまま ❌
// → 別の変数だから、status のチェックは data に影響しない
}
解決策:props オブジェクトを保持する
// ✅ props オブジェクトを保持する
function DataDisplay(props: DataDisplayProps) {
if (props.status === 'success') {
// props 全体が { status: 'success'; data: User[] } に narrow される
// だから props.data は User[] になる
return <UserList users={props.data} />
}
}
props はオブジェクトとして union 型を保持しています。props.status === 'success' をチェックすると、TypeScript は props オブジェクト全体を該当するバリアントに絞り込みます。
TypeScript 4.6 での改善
TypeScript 4.6 で一部改善されました。
// TypeScript 4.6+ では一部のケースで動く
function DataDisplay({ status, data }: DataDisplayProps) {
if (status === 'success' && data) {
// data は User[] に narrow される(場合がある)
}
}
ただし、複雑なケースや rest パターン({ status, ...rest })では依然として効きません。安全を取るなら props オブジェクトを保持する方が確実です。
union が増えすぎたら分割のサイン
andrewbran.ch では、「union だらけのコンポーネントは、複数コンポーネントに分割すべきサイン」と警告しています。
// ❌ union が多すぎる
type ModalProps =
| { variant: 'confirm'; onConfirm: () => void }
| { variant: 'alert'; message: string }
| { variant: 'form'; onSubmit: (data: FormData) => void; fields: Field[] }
| { variant: 'wizard'; steps: Step[]; currentStep: number }
| { variant: 'gallery'; images: Image[]; initialIndex: number }
// ... まだまだ続く
これはもう別コンポーネントに分けるべきです。
// ✅ 分割する
<ConfirmModal onConfirm={...} />
<AlertModal message={...} />
<FormModal onSubmit={...} fields={...} />
複数の独立した選択肢がある場合
「A or B」と「C or D」のような、独立した軸が複数ある場合はどうでしょう?
// ❌ すべての組み合わせを列挙すると爆発する
type ButtonProps =
| { size: 'sm'; variant: 'primary' }
| { size: 'sm'; variant: 'secondary' }
| { size: 'md'; variant: 'primary' }
| { size: 'md'; variant: 'secondary' }
| { size: 'lg'; variant: 'primary' }
| { size: 'lg'; variant: 'secondary' }
// ... 組み合わせ爆発
この場合は intersection を使います。
// ✅ 独立した軸は intersection で組み合わせる
type SizeProps = { size: 'sm' } | { size: 'md' } | { size: 'lg' }
type VariantProps = { variant: 'primary' } | { variant: 'secondary' }
type CommonProps = { children: ReactNode; onClick?: () => void }
type ButtonProps = CommonProps & SizeProps & VariantProps
これは TypeScript の分配法則によって、すべての有効な組み合わせに展開されます。
実践例:フォームの状態管理
最後に、実務でよく使うパターンを紹介します。
type FormState<T> =
| { status: 'idle' }
| { status: 'submitting' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error; lastInput: FormInput }
function useFormSubmit<T>(): [FormState<T>, (input: FormInput) => void] {
const [state, setState] = useState<FormState<T>>({ status: 'idle' })
const submit = async (input: FormInput) => {
setState({ status: 'submitting' })
try {
const data = await api.submit(input)
setState({ status: 'success', data })
} catch (error) {
setState({ status: 'error', error, lastInput: input })
}
}
return [state, submit]
}
error 状態のときだけ lastInput を保持しているのがポイントです。再送信時に使えます。optional props だと、どの状態で lastInput が有効なのか分かりません。
別解:複数の Presenter に分割するパターン
discriminated union で1つのコンポーネントにまとめる以外に、状態ごとに Presenter を分けて親が出し分けるパターンもあります。
// パターンA: 1つのコンポーネント + discriminated union
function DataDisplay(props: DataDisplayProps) {
switch (props.status) {
case 'idle': return null
case 'loading': return <Spinner />
case 'error': return <ErrorMessage error={props.error} />
case 'success': return <UserList users={props.data} />
}
}
// パターンB: 複数の Presenter + 親が出し分け
function IdleView() { return null }
function LoadingView() { return <Spinner /> }
function ErrorView({ error }: { error: Error }) { return <ErrorMessage error={error} /> }
function SuccessView({ data }: { data: User[] }) { return <UserList users={data} /> }
function DataDisplayContainer() {
const state = useDataFetch() // discriminated union を返す hooks
switch (state.status) {
case 'idle': return <IdleView />
case 'loading': return <LoadingView />
case 'error': return <ErrorView error={state.error} />
case 'success': return <SuccessView data={state.data} />
}
}
どちらを選ぶ?
| 観点 | 1コンポーネント + union | 複数 Presenter |
|---|---|---|
| Storybook | 1つに全状態の story | 各 Presenter ごとに story(シンプル) |
| テスト | 全状態をモックで切り替え | 各 Presenter を単独でテスト可能 |
| 型安全性 | props 型で状態を表現 | 各 Presenter の props が最小限 |
| ファイル数 | 1ファイル | 複数ファイル |
| 状態の一覧性 | switch 文で一目瞭然 | 親を見ないと全体像が分からない |
複数 Presenter が向いているケース
- 各状態の UI がそれぞれ複雑(50行以上など)
- Storybook で状態ごとにカタログ化したい
- Presenter-Container 分離を徹底している
1コンポーネント + union が向いているケース
- 各状態の UI がシンプル(数行程度)
- 1つのファイルで全状態を把握したい
- コンポーネント数を増やしたくない
スタイリング手法による選択
shadcn/ui(Tailwind)を使っている場合、各状態の Presenter を分けた方がいいケースが多いです。理由は className の重複問題。同じスタイルを複数の switch case に書くことになりがちです。
CSS Modules や scoped CSS を使っている場合、スタイルを .module.css で共有できるので、1コンポーネント + union でも問題ありません。
実際に tagged union のコンポーネントで switch 文の各 case 内に直接表示を書いたことがあります。「全部見通せて便利」と思ったんですが、状態が4つ、各状態にバリエーションがあって…気づいたら200行超えてました。Presenter 分けておけばよかった。
そもそも discriminated union を使うほど状態に変化があるコンポーネントって、それなりに複雑になる傾向があります。最初は「シンプルだから1ファイルで」と思っても、後から各状態に機能が追加されて膨らむことが多い。迷ったら Presenter 分離しておく方が安全かもしれません。
まとめ
optional props と discriminated union、どちらが正解というわけではありません。判断基準は明確です。
- 状態が相互排他的 → discriminated union
- 状態ごとに必要な props が違う → discriminated union
- 各 props が独立している → optional props
個人的には、迷ったら discriminated union を選ぶようにしています。「型が厳しすぎて困る」より「型が緩くてバグが出る」方が痛いからです。
Props Soup vs Compound Component と合わせて、コンポーネント設計の判断基準として活用してください。