exclusive-or-properties について
exclusive-or-properties は、設定やコンポーネントのプロパティ、API パラメーターの排他条件を扱う TypeScript 開発者向けスキルです。オプショナルな never プロパティとユニオン型により、選択肢がちょうど 1 つであることをコンパイル時に制約します。
主な機能
- 01オプショナルな never プロパティで排他性を強制
- 02ユニオン型で有効なバリアントを定義
- 03コンポーネントのプロパティと API パラメーターを扱う
- 04再利用可能な XOR ヘルパー型を提示
- 05組み合わせをコンパイル時に検査
活用シーン
- 01設定で選択元を 1 つだけ必須にする
- 02コンポーネントの操作方式を 1 つに制限
- 03排他的な API パラメーター形式をモデル化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill exclusive-or-propertiesインストール後、Claude Code で/exclusive-or-propertiesを入力して読み込まれたか確認します。
詳細
排他的論理和をモデル化するためにオプショナルな never プロパティを使用する
概要
複数のプロパティのうち、必ずちょうど1つだけが存在し、2つ以上は存在しない型が必要になることがあります。この「排他的論理和」(XOR)パターンは、コンポーネントのプロパティ、API パラメーター、設定オブジェクトでよく使われます。never 型を使ったオプショナルプロパティにより、この制約をコンパイル時に適用できます。
この手法は、オブジェクトのユニオンよりも型安全性が高く、実行時チェックよりも意図が明確になります。
この手法を使う場面
- 複数のプロパティのうち、必ずちょうど1つだけが存在する必要がある場合
- 相互排他的な設定オプションをモデル化する場合
- 代替設定を持つコンポーネントのプロパティ
- 排他的なバリエーションを持つ API パラメーター
- プロパティの無効な組み合わせを防ぐ場合
鉄則
「必ずちょうど1つだけ」の制約を適用するには、オプショナルな never プロパティを使用します。各バリエーションでは、対応するプロパティを必須にし、それ以外を never にします。
検出
次のような無効な組み合わせに注意してください。
// RED FLAGS - Invalid combinations allowed
interface Config {
url?: string;
filePath?: string;
content?: string;
}
// Can pass none, one, or all - too permissive!
// Runtime validation needed:
if ((config.url ? 1 : 0) + (config.filePath ? 1 : 0) + (config.content ? 1 : 0) !== 1) {
throw new Error('Exactly one source required');
}
問題点
interface LoadConfig {
url?: string; // Load from URL
filePath?: string; // Load from file
content?: string; // Load from string
}
// All these are allowed, but shouldn't be:
const bad1: LoadConfig = {}; // No source specified
const bad2: LoadConfig = { url: '...', filePath: '...' }; // Two sources
const bad3: LoadConfig = { url: '...', filePath: '...', content: '...' }; // All three
// Runtime check required
function load(config: LoadConfig) {
const sources = [config.url, config.filePath, config.content].filter(Boolean);
if (sources.length !== 1) {
throw new Error('Exactly one source required');
}
// ...
}
解決策:オプショナルな never プロパティ
type LoadConfig =
| { url: string; filePath?: never; content?: never }
| { url?: never; filePath: string; content?: never }
| { url?: never; filePath?: never; content: string };
// Valid - exactly one source:
const good1: LoadConfig = { url: 'https://example.com' };
const good2: LoadConfig = { filePath: '/path/to/file' };
const good3: LoadConfig = { content: 'raw content' };
// Invalid - caught at compile time:
const bad1: LoadConfig = {}; // Error: missing required property
const bad2: LoadConfig = { url: '...', filePath: '...' }; // Error: filePath must be undefined
const bad3: LoadConfig = { url: '...', content: '...' }; // Error: content must be undefined
コンポーネントのプロパティの例
type ButtonProps = {
label: string;
onClick: () => void;
} & (
| { href: string; to?: never; onPress?: never } // External link
| { href?: never; to: string; onPress?: never } // Router link
| { href?: never; to?: never; onPress: () => void } // Custom action
);
// Usage
<Button label="External" href="https://example.com" />
<Button label="Internal" to="/dashboard" />
<Button label="Action" onPress={handlePress} />
// Error: can't combine variants
<Button label="Bad" href="..." to="..." /> // Error!
API パラメーターの例
type SearchParams = {
limit?: number;
offset?: number;
} & (
| { query: string; filters?: never }
| { query?: never; filters: Filter[] }
);
// Valid
search({ query: 'typescript' });
search({ filters: [{ field: 'status', value: 'active' }] });
search({ query: 'typescript', limit: 10 });
// Invalid
search({}); // Error: need query or filters
search({ query: '...', filters: [...] }); // Error: can't have both
汎用 XOR ヘルパー
type XOR<A, B> =
| (A & { [K in keyof B]?: never })
| (B & { [K in keyof A]?: never });
// Usage
type Config = XOR<
{ url: string },
XOR<
{ filePath: string },
{ content: string }
>
>;
// Or for exactly one of many:
type ExactlyOne<T> = {
[K in keyof T]: { [P in K]: T[K] } & {
[P in Exclude<keyof T, K>]?: never;
};
}[keyof T];
type LoadConfig2 = ExactlyOne<{
url: string;
filePath: string;
content: string;
}>;
プレッシャー耐性プロトコル
排他的なプロパティを適用する際は、次の手順に従います。
- 排他性を特定する:どのプロパティが相互排他的ですか?
- バリエーションを作成する:各バリエーションでは、1つを必須にし、それ以外を
neverにします - バリエーションをユニオンにする:
|演算子で組み合わせます - 組み合わせをテストする:無効な組み合わせでエラーになることを確認します
- パターンを文書化する:特定の組み合わせが無効な理由を説明します
危険信号
| アンチパターン | 問題点 | 解決策 |
|---|---|---|
| すべてのプロパティがオプショナル | 何も指定しないことも、複数指定することも可能になります | オプショナルな never パターン |
| 実行時の検証のみ | コンパイル時ではなく、実行時にエラーになります | 型レベルで適用する |
| 「1つだけ使用すること」というコメント | 制約が適用されません | 型エラーになるようにする |
よくある言い訳
「実行時に検証します」
実際のところ:実行時の検証では、バグの発見が遅くなります。型レベルでの適用なら、すぐに検出できます。
「冗長すぎます」
実際のところ:XOR や ExactlyOne などのヘルパー型を使えば、繰り返しを減らせます。
「ユーザーは複数のオプションを使いたいかもしれません」
実際のところ:それが有効なら XOR は使いません。有効でないなら、型レベルで適用します。
クイックリファレンス
| パターン | 構文 | 意味 |
|---|---|---|
| XOR(プロパティが2つ) | { a: T, b?: never } | { a?: never, b: T } | ちょうど1つ |
| XOR(プロパティが3つ以上) | バリエーションのユニオン | ちょうど1つ |
| ヘルパー型 | XOR<A, B> | 再利用可能なパターン |
まとめ
コンパイル時に「必ずちょうど1つだけ」の制約を適用するには、オプショナルな never プロパティを使用します。これにより、実行時エラーの一種を根本から排除し、無効な状態を表現できないようにします。
参考資料
- Dan Vanderkam 著『Effective TypeScript』第2版
- 項目63:排他的論理和をモデル化するためにオプショナルな Never プロパティを使用する