valid-state-types について
valid-state-typesは、アプリケーション開発者向けのプログラミングスキルです。無効なフィールドの組み合わせを避ける型設計を案内し、判別共用体や、常に一緒に現れるフィールドのグループ化を重視します。
主な機能
- 01無効状態を招く設計の検出
- 02判別共用体による状態表現
- 03同時に必要なフィールドのグループ化
- 04単一の信頼できる情報源のモデル化
活用シーン
- 01リクエストの読み込み・結果状態の設計
- 02依存する複数の真偽値を含む型の改修
- 03ショッピングカートなど段階的な状態のモデル化
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ marius-townhouse/ effective-typescript-skills --skill valid-state-typesインストール後、Claude Code で/valid-state-typesを入力して読み込まれたか確認します。
詳細
常に有効な状態を表現する型を優先する
概要
有効な状態だけを表現できるように型を設計します。
型で無効な値の組み合わせを許してしまうと、コードの作成や読解が難しくなり、バグも発生しやすくなります。
このスキルを使う場面
- アプリケーションの状態型を設計するとき
- 関連するフィールドを持つ型を作成するとき
- 依存関係のある状態をモデル化するとき
- 存在し得ない状態に関するエラーをデバッグするとき
- 分かりにくい型定義をリファクタリングするとき
絶対に守るべきルール
NEVER design types that can represent invalid states.
例外はありません:
- 「このほうが簡単だから」という理由では認められません
- 「実行時に検証するから」という理由では認められません
- 「型を増やすのは手間がかかりすぎる」という理由では認められません
検出:「無効な状態」の兆候
本来あってはならない組み合わせを型が許しているなら、設計し直してください。
// ❌ VIOLATION: Allows invalid states
interface RequestState {
status: 'loading' | 'success' | 'error';
data?: string;
error?: string;
}
// These are all valid according to the type, but nonsensical:
const bad1: RequestState = { status: 'success' }; // Where's the data?
const bad2: RequestState = { status: 'error' }; // Where's the error?
const bad3: RequestState = { status: 'loading', data: 'x' }; // Loading but has data?
const bad4: RequestState = { status: 'success', error: 'x' }; // Success with error?
解決策:判別可能なユニオン
// ✅ CORRECT: Only valid states are representable
interface RequestPending {
status: 'pending';
}
interface RequestLoading {
status: 'loading';
}
interface RequestSuccess {
status: 'success';
data: string;
}
interface RequestError {
status: 'error';
error: string;
}
type RequestState = RequestPending | RequestLoading | RequestSuccess | RequestError;
// Now invalid states are impossible:
const bad: RequestState = { status: 'success' };
// ~~~ Property 'data' is missing in type '{ status: "success"; }'
実例:ページの状態
// ❌ BAD: Implicit relationships, invalid states possible
interface PageState {
isLoading: boolean;
error?: string;
currentPage: string;
data?: PageData;
}
// What does this mean?
const confusing: PageState = {
isLoading: true,
error: 'Network error',
currentPage: '/home',
data: someData, // Loading but has data? Has error but also data?
};
// ✅ GOOD: Explicit states, no invalid combinations
interface PagePending { state: 'pending' }
interface PageLoading { state: 'loading'; currentPage: string }
interface PageLoaded { state: 'loaded'; currentPage: string; data: PageData }
interface PageError { state: 'error'; currentPage: string; error: string }
type PageState = PagePending | PageLoading | PageLoaded | PageError;
// Now the render function is clear:
function renderPage(state: PageState) {
switch (state.state) {
case 'pending':
return renderPending();
case 'loading':
return renderSpinner(state.currentPage);
case 'loaded':
return renderData(state.data);
case 'error':
return renderError(state.error);
}
}
関連するフィールドは一緒に扱う
// ❌ BAD: Related fields can be independently undefined
interface Person {
name: string;
placeOfBirth?: string; // These should either both
dateOfBirth?: Date; // be present or both absent
}
// This is valid but probably wrong:
const person: Person = { name: 'Alice', placeOfBirth: 'NYC' }; // No date?
// ✅ GOOD: Group related fields
interface Person {
name: string;
birth?: {
place: string;
date: Date;
};
}
// Now they travel together:
function printBirth(person: Person) {
if (person.birth) {
// Both place AND date are guaranteed to exist
console.log(`Born in ${person.birth.place} on ${person.birth.date}`);
}
}
エールフランス447便のアンチパターン
不適切な状態設計が招いた悲劇的な例です:
// ❌ DANGEROUS: Independent controls with conflicting states
interface CockpitControls {
leftSideStick: number; // Pilot's stick position
rightSideStick: number; // Copilot's stick position
}
// What if they conflict? The code has to decide somehow.
function getStickSetting(controls: CockpitControls): number {
// Average them? Return left? Return right? No good answer!
return (controls.leftSideStick + controls.rightSideStick) / 2;
}
// ✅ SAFE: Single source of truth
interface CockpitControls {
stickAngle: number; // One stick, one truth
}
圧力に負けないための手順
1. 「型が増えると作業も増える」
圧力:「これらすべてのインターフェースを作るのは面倒です」
**回答:**無効な状態はバグの原因になります。型を作るのは簡単な部分です。
**対策:**最初に時間をかけてください。後でデバッグにかかる時間を節約できます。
2. 「実行時に検証する」
圧力:「コード内で無効な組み合わせをチェックしています」
**回答:**利用側はすべて、検証する必要があることを覚えていなければなりません。実際には覚えていないでしょう。
**対策:**無効な状態を表現できないようにしてください。検証の必要性をなくしましょう。
3. 「内部状態にすぎない」
圧力:「この型を使う外部コードはありません」
**回答:**内部コードもコードです。やはりバグは発生します。
**対策:**どこでも適切な型を設計してください。
危険信号 — 作業を止めて再検討してください
- 依存関係のある複数のブール型フラグ
- 常に一緒に現れるべきオプショナルフィールド
- オプショナルなデータフィールドを持つステータス列挙型
- 「X の場合は Y を設定する必要がある」と説明するコメント
- あり得ない組み合わせをチェックする検証コード
- 「通常は起こらない」デフォルトケースを含む switch 文
よくある正当化(いずれも不適切)
| 言い訳 | 実際のところ |
|---|---|
| 「そのほうが簡単」 | バグが発生するまでは簡単です。 |
| 「慎重に扱っています」 | 不注意は起こります。型は忘れません。 |
| 「ドキュメントに記載しています」 | ドキュメントは古くなります。型は古くなりません。 |
| 「インターフェースが多すぎる」 | バグが多すぎるよりはましです。 |
クイックリファレンス
| 悪いパターン | 良いパターン |
|---|---|
| 関連する複数のオプショナルフィールド | オプショナルなネストオブジェクト |
| ステータス文字列とオプショナルなデータ/エラー | 判別可能なユニオン |
| 依存関係のあるブール型フラグ | 判別可能なユニオン |
| 独立した複数の信頼できる情報源 | 信頼できる情報源を1つにする |
有効な状態型を設計する
- システムが取り得るすべての状態を列挙します
- 各状態について、必要なデータを判断します
- 各状態のインターフェースを作成します
- 判別可能なユニオンを使ってそれらを組み合わせます
- 確認します: 無効な状態を構築できてしまいませんか?
// Example: Shopping Cart
interface EmptyCart { state: 'empty' }
interface ActiveCart { state: 'active'; items: CartItem[] }
interface CheckoutCart { state: 'checkout'; items: CartItem[]; payment: PaymentInfo }
interface CompletedCart { state: 'completed'; orderId: string }
type ShoppingCart = EmptyCart | ActiveCart | CheckoutCart | CompletedCart;
要点
無効な状態を表現できるなら、無効な状態が発生します。
有効な状態だけを表現できる型を設計してください。判別可能なユニオンを使い、関連するフィールドをまとめましょう。そうすれば、コードの作成や理解が容易になり、壊れにくくなります。
参考資料
Dan Vanderkam 著『Effective TypeScript』の項目29「常に有効な状態を表現する型を優先する」に基づいています。