TypeScript Discriminated Unions: Safe Narrowing and Checks

TypeScript Discriminated Unions: Safe Narrowing and Checks

前提条件: コード サンプルには TypeScript 4.0 以降と tsconfig.json と "target": "ES2016" 以上、そして "strict": true。の FormState 再帰型エイリアスには TypeScript 3.7 以降が必要です。

ブール値フラグとオプションのフィールドの組み合わせは、TypeScript コードベースにおける不可能状態のバグの一般的な原因です。識別共用体はコンパイル時にこれらの無効な状態を排除するため、コンパイラはフィールドを不正に結合するオブジェクトを拒否します。この記事では、最も単純な形式から、ブール値からメンバーを構築する際の一般的な落とし穴を経て、網羅的なレデューサーを備えた完全に型指定されたマルチステップ フォーム ステート マシンまでのパターンを追跡します。

目次

ブール値フラグとオプションのフィールドの問題

あなたに嘘をつく平らな物体

一般的なパターン、つまり次のような形状の応答オブジェクトを考えてみましょう。 { success: boolean; data?: string[] }どこ data の場合にのみ存在します success は true。 TypeScript ではこの関係を強制することはできません。型システムが認識するもの data として string[] | undefined の値に関係なく success両方のフィールドが単一のインターフェイスに属しているためです。呼び出し元の構築を妨げるものは何もありません { success: true } なし dataまたは { success: false, data: ['oops'] }。

interface ApiResponse {
  success: boolean;
  data?: string[];
}

function processResponse(res: ApiResponse) {
  if (res.success) {
    
    
    console.log(res.data!.join(', '));
  }
}


try {
  processResponse({ success: true }); 
} catch (e) {
  console.error('Caught expected crash:', (e as Error).message);
}

非 null アサーションは本当の問題を隠します。この型は、ビジネス ロジックが不可能と判断し、コンパイラが対応できない状態を許可します。

なぜ if (obj.b) オプションのフィールドを狭めない

TypeScript の制御フローの絞り込みは、それを囲んでいるオブジェクトの形状ではなく、チェック対象のプロパティの型に作用します。型が共用体ではなく単一インターフェイスの場合、チェック if (res.success) 狭くなる res.success から boolean に true そのブランチ内では、ただし、タイプは変更されません res 自体。オブジェクトの形状は残ります ApiResponseそして data 滞在します string[] | undefined。オブジェクトの形状を絞り込むには、まずその型が個別の型の結合である必要があります。この結合がなければ、コンパイラーは何も絞り込むことができません。

差別的な結合: コアパターン

フラグをリテラルの判別式に置き換える

識別共用体は、各バリアントで一意の文字列、数値、またはブール リテラルのタイプを持つ共通のプロパティを共有する複数のオブジェクト タイプを結合します。この共有特性を判別特性と呼びます。このパターンは、タグ付き共用体または代数データ型とも呼ばれます。コードが判別式に基づいて分岐すると、TypeScript はオブジェクト全体を特定のバリアントに絞り込み、そのバリアントのすべてのフィールドをアサーションなしで使用できるようにします。

type ApiResponse =
  | { success: true; data: string[] }
  | { success: false };

function processResponse(res: ApiResponse) {
  switch (res.success) {
    case true:
      
      console.log(res.data.join(', ')); 
      break;
    case false:
      
      console.log('Request failed');
      break;
  }
}


コンパイラは不可能な状態を完全に拒否します。もし success は true、 data 存在する必要があります。もし success は false提供する data 型エラーが発生します。

ブール変数から共用体メンバーを構築する

共用体メンバーがランタイムから構築されるときに別の構築上の問題が発生する boolean。型指定された変数 boolean 労働組合です true | falseそしてその物体 { b: flag, c: value } のメンバーの 1 人に適合する必要があります Result 下に。 TypeScript 3.5 より前では、コンパイラはその割り当てを拒否しました (3.4.5 でエラーを確認しました)。 3.5 以降、判別式が次の場合にそれを受け入れます。 boolean またはリテラルの別の小さな結合であるため、現在のコンパイラでは、以下のコメントのコードがコンパイルされます。

この受け入れは便宜的なものであり、オブジェクトが有効であることを保証するものではありません。受け入れられたオブジェクトは引き続き運ぶことができます c いつ b は false。各バリアントを独自のブランチに構築すると、データが正確に保たれ、すべてのバージョンで動作します。古いコンパイラをサポートしている場合は、既存のスタック オーバーフローの回答にも古いエラーが表示されます。

type Result =
  | { b: true; c: number }
  | { b: false };







function makeResultFixed(flag: boolean, value: number): Result {
  if (flag) {
    return { b: true, c: value };
  } else {
    return { b: false };
  }
}


function createResult<B extends true | false>(
  b: B,
  ...args: B extends true ? [c: number] : []
): Result {
  if (b) {
    const c = (args as [number])[0];
    if (typeof c !== 'number') {
      throw new Error('createResult: c is required when b is true');
    }
    return { b: true, c };
  }
  return { b: false };
}

最初のオプションでは、各バリアントを独自のブランチにビルドします。各ブランチは、1 つの共用体メンバーに正確に一致するオブジェクト リテラルを返します。 c の場合にのみ存在します b は true。 2 番目のオプションでは、次のように制約されたジェネリック型パラメーターを使用します。 true | falseこれにより、コンパイラーは各呼び出しサイトで引数のリテラル型に基づいて正しい分岐を解決できるようになります。ランタイム ガードは、発信者が幅広のパスを通過するのを防ぎます。 boolean この値は、コンパイル時の条件付き残りの型保証 ( boolean-typed 引数は残りの型を作成します [c: number] | [])。

実際の絞り込み: switch、 inおよび制御フロー

switch ステートメントとリテラルの判別式

switch 判別プロパティに基づいて、和集合を 1 つあたり 1 つのバリアントに絞り込みます。 case ブロック。それぞれ case ブランチでは、そのバリアントに定義されたフィールドのみが表示されます。その間 if/else if チェーンは狭くする目的でも同様に機能します。 switch これは、バリアントごとに 1 つのブランチをマップし、 default 句。

の in 代替判別式としての演算子

一意のプロパティが 1 つのバリアントにのみ存在する場合、 in 演算子は、専用の判別フィールドなしで絞り込むことができます。チェック中 if ('data' in obj) を含むバリアントに絞り込みます。 data。ただし、このアプローチは明示的ではなく、体系的に網羅するのが難しく、2 つのバリアントがプロパティ名を共有すると機能しません。 in check ではそれらを区別できなくなります。これは、文字通りの判別式を置き換えるのではなく、補足するために使用します。

type CSVFieldResult =
  | { kind: 'number'; value: number }
  | { kind: 'nan'; raw: string };

function parseCSVField(raw: string): CSVFieldResult {
  
  const parsed = Number(raw);
  if (Number.isNaN(parsed)) {
    return { kind: 'nan', raw };
  }
  return { kind: 'number', value: parsed };
}





const result = parseCSVField('42abc');
if (result.kind === 'number') {
  console.log(result.value);
} else {
  console.log('Not a number:', result.raw);
}

この例では、オブジェクト構成パターンの外側での区別された共用体縮小を示します。使用します Number() それよりも parseInt 末尾に数字以外の文字を含む文字列を正しく拒否するには ('42abc' → NaN) および 10 進数値を保持するため ('3.14' → 3.14)。戻り値の型を区別すると、呼び出し元は両方の結果を明示的に処理する必要があります。

徹底したチェック never タイプ

網羅性とは何か、そしてそれがなぜ重要なのか

網羅的とは、分岐ロジックが共用体のすべてのメンバーを処理することを意味します。新しいバリアントが共用体に追加されると、コンパイラはすべての未処理のケースにフラグを立てます。網羅的ではなく、バリアントの追加は既存のブランチを静かに通過し、通常は default このケースは、異なる一連の状態に対して記述されており、リファクタリングが本番環境に到達した後でのみ表面化するランタイム エラーを生成します。

の assertNever パターン

正規の網羅性手法では、 assertNever の機能 default の支店 switch。すべてのバリアントが処理されると、残りの型は default は never。バリアントが見つからない場合、その型は存在しません。 neverそして、への割り当て never パラメータがコンパイル時に失敗します。純粋な TypeScript コードベースでは、この行は実行時に到達できません。これにより、コンパイル時のチェックをバイパスする JavaScript 呼び出し元や安全でない型アサーションから保護されます。

export function assertNever(x: never): never {
  let description: string;
  try {
    description = JSON.stringify(x);
  } catch {
    description = String(x);
  }
  throw new Error(`Unexpected value: ${description}`);
}

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number }; 

function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle': return Math.PI * (shape.radius ** 2);
    case 'square': return shape.side ** 2;
    
    default: return assertNever(shape);
    
    
  }
}

エラーは、テストが実行される前、コードがコンパイルされた瞬間に表示されます。追加 case 'triangle' それを解決し、網羅性を回復します。

マルチステップのフォームステートマシンの構築

州連合の定義

あ FormState と入力します kind 判別式は、各ステップを個別のバリアントとしてモデル化します。各バリアントにはそのステップに関連するデータのみが含まれるため、不可能な組み合わせは表現できなくなります。の FormState type は再帰的な型エイリアス (TypeScript 3.7 以降でサポート) を使用して、 error 参照するバリアント FormState 自体。

type FormState =
  | { kind: 'step1'; name: string; email: string }
  | {
      kind: 'step2';
      step1Data: { name: string; email: string };
      address: string;
      city: string;
    }
  | {
      kind: 'submitting';
      step1Data: { name: string; email: string };
      step2Data: { address: string; city: string };
    }
  | {
      kind: 'done';
      confirmationId: string;
    }
  | {
      kind: 'error';
      message: string;
      lastState: Exclude<FormState, { kind: 'error' }>;
    };

の step2 バリアントには検証済みのものが含まれます step1Dataステップ 1 を完了せずにステップ 2 に到達することは構造的に不可能です。の error バリアントキャリー lastState 再試行ロジック用ですが、ネストされたエラーは除外されます。 Exclude。

アクションを差別的な結合として定義する

アクションは状態間の遷移をモデル化します。各アクションは、独自のタグ付き共用体メンバーです。の NEXT アクションはステップ固有のバリアントに分割されるため、各遷移はソース ステップに関連するデータのみを運び、構造的に不完全なペイロードを防ぎます。

type FormAction =
  | { type: 'NEXT_STEP1'; name: string; email: string }
  | { type: 'NEXT_STEP2'; address: string; city: string }
  | { type: 'SUBMIT' }
  | { type: 'SUBMIT_SUCCESS'; confirmationId: string }
  | { type: 'SUBMIT_FAILURE'; message: string }
  | { type: 'RETRY' }
  | { type: 'RESET' };

徹底的な減速機

リデューサーはネストされたものを使用します switch 発言。外側のスイッチはオンにディスパッチされます state.kindインナーオン action.type ここでは、複数のアクションが 1 つの状態に適用されます。どちらのレベルでも使用します assertNever 網羅性を保証するため。

それぞれのインナー switch すべてのメンバーを列挙する必要があります FormAction処理された遷移または明示的な no-op として、 default 見るための分岐 never。この冗長性はパターンのコストですが、新しいアクション タイプを追加することで元が取れます。 FormAction リストされていないすべての内部スイッチでコンパイル エラーが発生します。

import { assertNever } from './shared/assertNever';

const INITIAL_STATE: FormState = { kind: 'step1', name: '', email: '' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (state.kind) {
    case 'step1':
      switch (action.type) {
        case 'NEXT_STEP1':
          return {
            kind: 'step2',
            step1Data: { name: action.name, email: action.email },
            address: '',
            city: '',
          };
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state; 
        default: return assertNever(action);
      }

    case 'step2':
      switch (action.type) {
        case 'NEXT_STEP2':
          return {
            kind: 'step2',
            step1Data: state.step1Data,
            address: action.address,
            city: action.city,
          };
        case 'SUBMIT':
          return {
            kind: 'submitting',
            step1Data: state.step1Data,
            step2Data: { address: state.address, city: state.city },
          };
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state;
        default: return assertNever(action);
      }

    case 'submitting':
      switch (action.type) {
        case 'SUBMIT_SUCCESS': return { kind: 'done', confirmationId: action.confirmationId };
        case 'SUBMIT_FAILURE': return { kind: 'error', message: action.message, lastState: state };
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'RETRY': case 'RESET':
          return state;
        default: return assertNever(action);
      }

    case 'done':
      switch (action.type) {
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE': case 'RETRY':
          return state;
        default: return assertNever(action);
      }

    case 'error':
      switch (action.type) {
        case 'RETRY': return state.lastState;
        case 'RESET': return INITIAL_STATE;
        case 'NEXT_STEP1': case 'NEXT_STEP2': case 'SUBMIT': case 'SUBMIT_SUCCESS': case 'SUBMIT_FAILURE':
          return state;
        default: return assertNever(action);
      }

    default: return assertNever(state);
  }
}

これがブールフラグアプローチに勝る理由

次のようなフラットな代替案 { step: number; isSubmitting: boolean; error?: string; data?: object } 決して存在すべきではない状態を許可します。 isSubmitting: true と error 同時に定義され、 step: 3 ステップ 1 または 2 のデータがない、または error 同時に実装されます isSubmitting 残る true。フラット型では、次のようにガードを書きます。 if (state.isSubmitting && !state.error) あらゆる消費現場で。差別組合はこうした警備員を構造的に排除する。 6 番目の状態を共用体に追加すると、リデューサーと使用側コードがそれを処理できない場所でコンパイル エラーが発生します。

比較: ?: never vs. 差別的な組合

基準 ?: never パターン 差別的な組合
狭まる人間工学 必要 in 述語のチェックまたは型。オンにする単一のプロパティはありません switch (obj.kind) 一気に狭くなる
網羅性エラー 内蔵メカニズムはありません。手動チェックが必要 assertNever で default コンパイル時にエラーが発生する
不健全な共変書き込み 不健全な可能性: 構造サブタイプにより、より広い型の変数が排他的フィールド制約を回避できる可能性があります。やはり実行時に検証する必要があります 各バリアントは別個のタイプです。共変書き込みは形状をサイレントにマージしません
明示的な判別式が必要ですか? いいえ。構造的な特性の欠如に依存しています。 はい。すべてのバリアント間で共有リテラル プロパティが必要です
最適な使用例 小さな 2 つのバリアント オブジェクトに、 kind フィールドは非現実的です 3 つ以上のバリアント、ステート マシン、リデューサー、網羅性を必要とするコード パス

の ?: never パターンは、プロパティを type でオプションとしてマークすることで機能します neverこれにより、非undefined 割り当てられる値。 undefined そうでない限り、割り当て可能のままです exactOptionalPropertyTypes: true に設定されています tsconfig.json。これは軽量の排他的フィールド制約には便利ですが、識別共用体が提供する絞り込みや網羅性の保証はありません。

実践的なガイドラインと落とし穴

差別的な組合をいつ利用すべきか

差別結合は次のシナリオによく適合します。

  • 共通の形状を共有する 3 つ以上の州
  • 遷移が定義されたステート マシンまたはワークフロー
  • 結果ごとに異なる形状を持つ API 応答タイプ
  • 遷移を徹底的に処理する必要がある Reducer パターン

2 バリアント共用体は、コンパイル時に網羅性が得られ、パターンから絞り込まれます。 ApiResponse 前述の例で示しています。

よくある間違い

使用する string 判別式の文字列リテラルの代わりに、完全に狭くなります。と kind: string すべてのバリエーションで、 switch (x.kind) 共用体は狭められないため、次のようなバリアントのみのフィールドを読み取ります。 x.a 内部 case 'a' エラー TS2339 (ユニオンにプロパティが存在しません) で失敗します。

ランタイムからユニオンメンバーを構築する boolean 分岐を行わないと、コンパイラが受け入れた場合でも、他のバリアントが持つべきではないフィールドを持つオブジェクトが生成される可能性があります (上記を参照)。

ユニオンを 1 レベル以上深くネストすると (判別ユニオンが別の判別ユニオンの中にある)、絞り込みが複雑になり、リデューサーが読みにくくなります。記述的なフラット判別式を好む kind のような値 'step2-loading' ネストされた構造上。

次のステップ

ブール フラグは、実行時の想定の背後にあり得ない状態を隠します。識別された共用体はコンパイル時にそれらの状態を明らかにし、次のような徹底的なチェックを行います。 assertNever これにより、今後の共用体への追加が既存のコード パスを暗黙のうちにすり抜けることができないようになります。

コードベース内で 1 つのブールフラグ型を見つけ、それを識別共用体にリファクタリングし、不可能な状態が安全であると想定されているすべての場所をコンパイラに明らかにさせます。



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *