やあ。今日も元気にコード書いてるかい?
「ボタンコンポーネント作ったんだけど、なんだかPropsがカオスになってきた……」
「`href`が渡されたら``タグになって、渡されなかったら`
中級の壁を突破しようとしている君なら、一度はこんな悩みに直面したことがあるはずだ。オプショナルプロパティ(`?`)をとりあえずペタペタ貼り付けて、「まあ動くからいっか」で済ませていないかい? その場しのぎの型定義は、半年後の自分やチームメンバーを地獄に突き落とす呪いになる。
今回は、TypeScriptの真骨頂であり、Reactコンポーネントの設計を劇的に美しく・安全にする「Discriminated Unions(タグ付きユニオン)」を使ったPropsの型安全な分岐について、現場のリアルな知見を交えて徹底的に解説しよう。
—
なぜオプショナルプロパティの乱用は「悪」なのか?
まずは、よくある「やってはいけない」アンチパターンから見ていこう。リンク(``)としても、普通のボタン(`
君が書きがちなコードは、きっとこんな感じじゃないか?
// 🔴 やってはいけないアンチパターンの例
type BadButtonProps = {
children: React.ReactNode;
variant?: ‘primary’ | ‘secondary’;
href?: string; // リンク先(あるかもしれないし、ないかもしれない)
onClick?: () => void; // クリックハンドラー(あるかもしれないし、ないかもしれない)
target?: string; // おいおい、hrefがないのにtargetがあるのはおかしくないか?
};
一見すると、「柔軟で使いやすいじゃん」と思うかもしれない。だが、TypeScriptのコンパイラから見れば、この型は「何でも許すザル」だ。
この定義だと、「`href`がないのに`target=”_blank”`が指定されている」という、ブラウザの挙動的に意味不明な組み合わせや、「`href`があるのに`onClick`で独自のトラッキングを仕込もうとして、実は``タグのデフォルト挙動と喧嘩してバグる」といった矛盾を、TypeScriptは一切検知してくれない。
結果どうなるか? 実行時エラー、あるいは「なぜか動かない」という原因不明の不具合に怯えながら、コンポーネント内部で地道な`if`文の山を書く羽目になる。これが現場の生産性を落とす元凶だ。
—
救世主:「Discriminated Unions(タグ付きユニオン)」とは何か?
ここで登場するのが、Discriminated Unions(タグ付きユニオン)だ。
考え方は非常にシンプル。「ある特定のプロパティ(タグ)の値によって、許容される他のプロパティのセットを完全に切り替える」という手法だ。
TypeScriptの型システムは、共通の「タグ(識別子)」を持つ複数の型をユニオン(`|`)で結んだとき、そのタグの値を確認する(Narrowing / 型の絞り込み)ことで、どの型であるかを完璧に特定できるという強力な性質を持っている。
これをReactのPropsに応用すると、「このモードの時は、このPropsしか存在してはならない」という排他的な制約を、コンパイル時に強制できるようになる。
—
現場で使える!完璧なButtonコンポーネントの実装例
百聞は一見にしかず。実際にDiscriminated Unionsを使って、リンクとしてもボタンとしても安全に使える `Button` コンポーネントを書いてみよう。
エディタにそのまま貼り付けて、型補完の気持ちよさを体感してほしい。
import React, { ComponentPropsWithoutRef } from ‘react’;
// 1. ボタンとして振る舞う場合の型定義
type ButtonAsButtonProps = {
intent: ‘button’; // ←これが「タグ」になる識別子!
onClick?: ComponentPropsWithoutRef<'button'>[‘onClick’];
href?: never; // hrefの存在を完全に禁止する
target?: never; // targetも当然禁止
};
// 2. リンクとして振る舞う場合の型定義
type ButtonAsLinkProps = {
intent: ‘link’; // ←これが「タグ」になる識別子!
href: string; // リンクの場合は必須
target?: ‘_blank’ | ‘_self’;
onClick?: ComponentPropsWithoutRef<'a'>[‘onClick’];
};
// 3. 共通のProps
type ButtonCommonProps = {
children: React.ReactNode;
variant?: ‘primary’ | ‘secondary’;
};
// 4. ユニオン型で結合する
export type ButtonProps = ButtonCommonProps & (ButtonAsButtonProps | ButtonAsLinkProps);
export const Button: React.FC
const { children, variant = ‘primary’, …rest } = props;
// 共通のスタイルクラス
const className = `btn btn-${variant}`;
// 🧠 ここがTypeScriptの型絞り込み(Narrowing)の魔法!
// `intent`の値を見るだけで、restの中身が完全に保証される。
if (rest.intent === ‘link’) {
// このブロックの中では、restは ButtonAsLinkProps として扱われるため、
// hrefが存在することが保証され、TypeScriptは絶対に怒らない。
const { intent, …linkProps } = rest;
return (
{children}
);
}
// ここに到達した時点で、restは必ず ButtonAsButtonProps に絞り込まれている。
// 万が一 href を渡そうものなら、コンパイルエラーで即座に弾かれる。
const { intent, …buttonProps } = rest;
return (
);
};
この実装の何がスゴいのか?
1. `intent` プロパティによる完全な排他制御
開発者はコンポーネントを使う際、最初に `intent=”button”` なのか `intent=”link”` なのかを強制的に選ばされる。これにより、「ボタンなのにhrefがある」という矛盾が物理的に不可能になる。
2. `never` 型の暴力的なまでの優しさ
`ButtonAsButtonProps` の中で `href?: never;` と定義している。これは、「このプロパティには何も値を入れさせない(undefinedすら許さない)」という強烈な意思表示だ。もし利用側が `intent=”button”` なのに `href` を渡そうとすると、TypeScriptは親切に「おい、そのプロパティは存在しねぇよ」と赤く波線を出して教えてくれる。
3. 内部実装での型安全な分岐
Reactコンポーネントの実装側でも、`if (rest.intent === ‘link’)` と書くだけで、TypeScriptのコントロールフロー分析が働き、安全に `` タグ用のPropsと`
—
ブラウザやTypeScriptの裏側で何が起きているのか?
中級からシニアへステップアップするなら、「裏側で何が起きているか」を知っておく必要がある。
TypeScriptのコンパイラは、ユニオン型(`A | B`)を評価するとき、「判別可能なプロパティ(Discriminated Property)」を探す。今回の例で言えば `intent` がそれに該当する。
コンパイラは、`intent` というリテラル型(`’button’` または `’link’`)の値をチェックするコード(`rest.intent === ‘link’`)に遭遇すると、「あ、じゃあこの分岐の中では候補から `ButtonAsButtonProps` を除外して、`ButtonAsLinkProps` だけに絞り込もう」という推論(Type Narrowing)を自動で行う。
これは、ランタイムのJavaScriptには一切影響を与えない(コンパイル時にすべて消え去る)。つまり、実行時のパフォーマンスコストをゼロに抑えながら、開発時のみ最高峰の型安全性とIDEの補完機能を手に入れられるという、フロントエンド開発において最強のチート技なのだ。
—
現場でよくある活用シーン
このDiscriminated Unions、実はボタン以外でもめちゃくちゃ使える。
- モーダルやダイアログのコンポーネント
- `type: ‘alert’` のときは確認ボタンが1つ、`type: ‘confirm’` のときは「はい」「いいえ」の2つのハンドラーを必須にする。
- フォームの入力フィールド(Input)
- `as: ‘select’` のときは `options` 配列の渡入を必須にし、`as: ‘input’` のときは `type` 属性(text, numberなど)を必須にする。
- APIのステータス表示コンポーネント
- `status: ‘loading’` のときはスピーカー、`status: ‘error’` のときはエラーメッセージ、`status: ‘success’` のときはデータ本体を必須にする。
「Propsの組み合わせによって、他のPropsの必須/不要が変わる」という要件に出会ったら、脊髄反射でDiscriminated Unionsを思い出してほしい。
—
まとめ:型定義は「動かすため」ではなく「バグらせないため」にある
今回は、Discriminated Unionsを使ったPropsの型安全な分岐について解説した。
- オプショナルプロパティの乱用はバグの温床。
- タグ(識別子となるプロパティ)を使って、Propsの組み合わせを排他的に制限する。
- `never` 型を活用して、意図しないプロパティの入力をコンパイル時にブロッキングする。
- 内部の条件分岐と連動させることで、型アサーションに頼らないクリーンな実装ができる。
型定義は、ただTypeScriptのエラーを黙らせるための儀式ではない。「未来のバグを未然に防ぎ、チームメンバーが迷わず使える最高のドキュメント」として機能させるものだ。
さあ、今すぐ自分のプロジェクトのコンポーネントフォルダを開いて、「とりあえず `?` をつけてごまかしているProps」をリファクタリングしに行こうぜ!

コメント