T-CREATOR

<div />

TypeScriptでフィーチャーフラグを安全に運用する設計 判別可能unionで事故を防ぐ

2025年12月24日
TypeScriptでフィーチャーフラグを安全に運用する設計 判別可能unionで事故を防ぐ

フィーチャーフラグで新機能のロールアウトや A/B テストを管理していると、「このフラグが有効なのに必要なパラメータが渡されていない」という運用事故に遭遇したことはないでしょうか。

実際に業務でフィーチャーフラグシステムを運用してきた中で、boolean 型だけの単純な実装では型の不整合が原因でバグが混入しやすく、コードレビューでも見落とされがちでした。特に複数の機能バリエーションを管理する際、実行時まで不整合が発覚しないケースが多発していたのです。

この記事では、TypeScript の 判別可能 union(Discriminated Unions) を使い、フラグの状態とパラメータを型レベルで結びつけることで、コンパイル時に不整合を検出する設計を解説します。実際のプロジェクトで採用した設計パターンと、運用で学んだ注意点を共有しますので、型安全なフィーチャーフラグシステムを構築したいエンジニアの参考になれば幸いです。

検証環境

本記事では、以下の環境で動作確認を行っています。

  • OS: macOS Sequoia 15.2
  • Node.js: 22.12.0(LTS)
  • 主要パッケージ:
    • TypeScript: 5.7.2
    • Next.js: 15.1.0
    • React: 19.0.0
    • Jest: 29.7.0
  • 検証日: 2025 年 12 月 24 日

フィーチャーフラグ運用で直面した型安全性の課題

プロダクション環境でのフラグ活用シーン

実務でフィーチャーフラグを導入する際、主に以下のようなシナリオで活用されます。

#ユースケース目的典型的な実装パターン
1カナリアリリース一部ユーザーで新機能を検証ユーザー ID/グループでの絞り込み
2A/B テスト複数バリエーションの効果測定ランダム振り分け+実験 ID 管理
3緊急停止スイッチ障害時の即座な機能無効化リモート設定との連携
4段階的ロールアウトパーセンテージでの段階公開確率ベースの判定処理

以下の図は、ユーザーリクエストに対してフィーチャーフラグシステムが判断を行い、状態に応じて異なる機能を提供する基本フローを示しています。

mermaidflowchart LR
  user["ユーザー"] -->|リクエスト| app["アプリケーション"]
  app -->|フラグ確認| flag["フィーチャーフラグ<br/>システム"]
  flag -->|有効| featureA["新機能 A"]
  flag -->|無効| featureB["既存機能 B"]
  featureA -->|レスポンス| user
  featureB -->|レスポンス| user

フラグ状態による分岐処理が適切に実装されることで、デプロイせずに機能の切り替えが可能になります。

boolean 型ベースの実装で遭遇した実行時エラー

初期のフィーチャーフラグ実装では、シンプルに boolean 型で管理していました。

以下は、実際のプロジェクトで使っていた初期実装です。

typescript// 初期の型定義:boolean型で管理
interface FeatureFlags {
  enableNewUI: boolean;
  enableBetaPayment: boolean;
}

この定義では、フラグの有効/無効しか表現できません。

実際の業務では、フラグに付随する設定値が必要なケースが頻繁にありました。

typescript// フラグに関連するパラメータを別途定義
interface FeatureFlagConfig {
  flags: FeatureFlags;
  rolloutPercentage?: number;
  experimentId?: string;
}

しかし、この構造では型の不整合が発生しやすくなります。

typescript// コンパイルは通るが、実行時にエラーが発生する例
const config: FeatureFlagConfig = {
  flags: {
    enableNewUI: true, // フラグは有効
    enableBetaPayment: false,
  },
  // rolloutPercentageが必要なのに未定義
};

function renderUI(config: FeatureFlagConfig) {
  if (config.flags.enableNewUI) {
    // rolloutPercentageがundefinedになる
    const percentage = config.rolloutPercentage!;
    // 実行時に想定外の動作が発生
  }
}

実際にこのコードで本番環境にデプロイしたところ、以下のようなエラーログが記録されました。

bashTypeError: Cannot read property 'toFixed' of undefined
  at renderUI (ui.renderer.ts:42)
  at processRequest (app.ts:128)

このエラーは、フラグが有効なのにrolloutPercentageが渡されていなかったために発生しました。

コードレビューでも見落とされる型の不整合

実務では、以下のような不整合がコードレビューをすり抜けてしまうケースがありました。

typescript// レビューで見落とされた問題コード
const invalidConfig: FeatureFlagConfig = {
  flags: {
    enableNewUI: false, // フラグは無効
    enableBetaPayment: false,
  },
  rolloutPercentage: 50, // 無効なのにパラメータを設定
  experimentId: "exp-001", // 使われないパラメータ
};

TypeScript の型チェックではエラーにならないため、レビュアーも気づきにくい問題でした。

この状態でコードが運用されると、以下のような問題が発生します。

#問題の種類発生条件影響範囲
1TypeError必須パラメータが未定義ユーザー体験の破綻
2ロジックエラー無効なフラグで処理が実行データの不整合
3セキュリティリスクアクセス制御の判定ミス未公開機能の漏洩
4デバッグ困難実行時まで問題が顕在化しない調査コストの増大

判別可能 union による型の不整合を防ぐ設計判断

TypeScript の union 型と型ガードの仕組み

判別可能 union(Discriminated Unions)は、TypeScript の型システムを活用して、複数の異なる型を 1 つの型として扱う手法です。

ユニオン型の基本構造として、共通のプロパティ(判別プロパティ)を持つことで、TypeScript が型ガードにより型を自動的に絞り込みます。

以下は、判別可能 union の基本的な例です。

typescript// 判別プロパティとしてkindを使用
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number };

この定義により、kindの値に応じて異なるプロパティセットが要求されます。

TypeScript の型ガード機能により、条件分岐内で型が自動的に絞り込まれるため、安全にプロパティへアクセスできます。

typescript// 型ガードによる自動的な型の絞り込み
function getArea(shape: Shape): number {
  if (shape.kind === "circle") {
    // この分岐内では shape は { kind: "circle"; radius: number } 型
    return Math.PI * shape.radius ** 2;
  } else {
    // この分岐内では shape は { kind: "rectangle"; width: number; height: number } 型
    return shape.width * shape.height;
  }
}

この型安全な仕組みをフィーチャーフラグ設計に応用することで、フラグの状態とパラメータを型レベルで結びつけられます。

フラグ状態とパラメータを紐付ける型定義

実際のプロジェクトで採用した、判別可能 union を使ったフィーチャーフラグの型定義を紹介します。

以下の型定義では、enabledを判別プロパティとして使用し、有効/無効で異なるプロパティセットを定義しています。

typescript// 判別可能unionによるフィーチャーフラグの型定義
type FeatureFlag =
  | { enabled: false }
  | {
      enabled: true;
      variant: "A" | "B" | "C";
      rolloutPercentage: number;
    };

この定義により、enabledtrueの場合は必ずvariantrolloutPercentageが存在することが型で保証されます。

逆に、型の不整合がある場合はコンパイルエラーになります。

typescript// コンパイルエラーになる例(型安全)
const invalidConfig1: FeatureFlag = {
  enabled: true,
  // Error: Property 'variant' is missing in type
  //        '{ enabled: true; }' but required in type
  //        '{ enabled: true; variant: "A" | "B" | "C"; rolloutPercentage: number; }'
};

無効なフラグに不要なパラメータを設定した場合もエラーになります。

typescript// これもコンパイルエラー
const invalidConfig2: FeatureFlag = {
  enabled: false,
  variant: "A",
  // Error: Object literal may only specify known properties,
  //        and 'variant' does not exist in type '{ enabled: false; }'
};

正しい定義のみがコンパイルを通過します。

typescript// 正しい定義例
const validConfig1: FeatureFlag = {
  enabled: false,
};

const validConfig2: FeatureFlag = {
  enabled: true,
  variant: "B",
  rolloutPercentage: 30,
};

型ガードによる安全なプロパティアクセス

判別可能 union を使うと、条件分岐内で TypeScript が自動的に型を絞り込むため、安全にプロパティへアクセスできます。

以下の実装では、if (flag.enabled)の分岐内で型が自動的に絞り込まれています。

typescript// 型ガードを活用した実装
function handleFeature(flag: FeatureFlag): void {
  if (flag.enabled) {
    // この分岐内では flagの型が自動的に絞り込まれる
    // flag: { enabled: true; variant: "A" | "B" | "C"; rolloutPercentage: number }
    console.log(`Variant: ${flag.variant}`);
    console.log(`Rollout: ${flag.rolloutPercentage}%`);
  } else {
    // この分岐内では flagの型は { enabled: false }
    console.log("Feature is disabled");
    // console.log(flag.variant); // Error: Property 'variant' does not exist
  }
}

実行時エラーを防ぐために、型ガードが重要な役割を果たします。

以下の図は、判別可能 union と型ガードによる型の絞り込みと安全性の仕組みを示しています。

mermaidflowchart TD
  start["フラグ取得<br/>FeatureFlag型"] --> check{enabled?}
  check -->|true| narrow1["型ガードで自動絞り込み<br/>variant & rollout<br/>確実に存在"]
  check -->|false| narrow2["型ガードで自動絞り込み<br/>enabled: false型"]
  narrow1 --> safe1["安全にアクセス<br/>コンパイル時保証"]
  narrow2 --> safe2["variantへの<br/>アクセス不可<br/>コンパイルエラー"]
  safe1 --> result["実行時エラー無し"]
  safe2 --> result

コンパイル時に型が保証されるため、実行時エラーが発生しなくなります。この型安全性により、boolean 型ベースの実装で遭遇していた運用事故を防げるようになりました。

複数状態を持つフラグへの拡張設計

実際の業務では、無効/有効の 2 状態だけでなく、より複雑な状態管理が必要になることがあります。

以下は、実プロジェクトで採用した、複数の状態とモードを持つフィーチャーフラグの型定義です。

typescript// 複数状態を持つフィーチャーフラグの型定義
type AdvancedFeatureFlag =
  | { status: "disabled" }
  | { status: "enabled"; mode: "simple" }
  | {
      status: "enabled";
      mode: "ab-test";
      variants: ["A", "B"];
      experimentId: string;
    }
  | {
      status: "enabled";
      mode: "rollout";
      percentage: number;
      targetAudience: string[];
    };

この定義では、statusmodeを判別プロパティとして使い、それぞれの状態で必要なパラメータが異なります。

各パターンに対応した処理を実装する際、switch 文で分岐します。

typescript// パターンごとの処理実装
function processFeature(flag: AdvancedFeatureFlag): void {
  switch (flag.status) {
    case "disabled":
      console.log("Feature is disabled");
      break;

    case "enabled":
      // さらに mode で分岐
      handleEnabledFeature(flag);
      break;
  }
}

有効な状態では、さらにモードごとに処理を分岐します。

typescript// 有効状態のモード別処理
function handleEnabledFeature(
  flag: Extract<AdvancedFeatureFlag, { status: "enabled" }>
): void {
  switch (flag.mode) {
    case "simple":
      console.log("Simple mode enabled");
      break;

    case "ab-test":
      // experimentId と variants が確実に存在
      console.log(`A/B Test: ${flag.experimentId}`);
      console.log(`Variants: ${flag.variants.join(", ")}`);
      break;

    case "rollout":
      // percentage と targetAudience が確実に存在
      console.log(`Rollout: ${flag.percentage}%`);
      console.log(`Target: ${flag.targetAudience.join(", ")}`);
      break;
  }
}

この実装により、どのパターンでも必要なパラメータが型レベルで保証されます。

各状態と必要なパラメータの対応関係を表にまとめると、以下のようになります。

#ステータスモード必須パラメータ用途
1disabled-なし機能の完全停止
2enabledsimpleなし単純な有効化
3enabledab-testvariants, experimentIdA/B テスト実施
4enabledrolloutpercentage, targetAudience段階的ロールアウト

型安全なフィーチャーフラグシステムの実装手順

ステップ 1:機能ごとの型定義ファイル作成

実際のプロジェクトで使える、型安全なフィーチャーフラグシステムを構築していきます。

まず、フィーチャーフラグの基本的な状態を定義します。

typescript// features.types.ts
// フィーチャーフラグで使用する状態の型定義
export type FeatureFlagStatus = "disabled" | "enabled" | "beta" | "deprecated";

新 UI フィーチャーの型定義を作成します。無効状態と有効状態で異なるプロパティセットを定義しています。

typescript// 新UI機能のフィーチャーフラグ型
export type NewUIFeature =
  | { status: "disabled" }
  | {
      status: "enabled";
      theme: "light" | "dark";
      animationEnabled: boolean;
    };

決済機能のフィーチャーフラグは、さらに複雑な状態遷移を持ちます。

typescript// 決済機能のフィーチャーフラグ型
export type PaymentFeature =
  | { status: "disabled" }
  | {
      status: "beta";
      allowedUsers: string[];
      testMode: boolean;
    }
  | {
      status: "enabled";
      providers: string[];
      defaultProvider: string;
    };

この型定義により、各フィーチャーフラグの状態とそれに紐づくパラメータが明確になります。

ステップ 2:フラグ管理システムの実装

フィーチャーフラグを一元管理するための型定義を作成します。

typescript// featureFlags.ts
import { NewUIFeature, PaymentFeature } from "./features.types";

// アプリケーション全体のフィーチャーフラグ集約型
export interface FeatureFlags {
  newUI: NewUIFeature;
  payment: PaymentFeature;
}

フラグを取得・更新するための型安全なマネージャークラスを実装します。

typescript// フィーチャーフラグマネージャークラス
export class FeatureFlagManager {
  private flags: FeatureFlags;

  constructor(initialFlags: FeatureFlags) {
    this.flags = initialFlags;
  }

  // 型安全なゲッター:NewUIFeature型を返す
  getNewUIFeature(): NewUIFeature {
    return this.flags.newUI;
  }

  // 型安全なゲッター:PaymentFeature型を返す
  getPaymentFeature(): PaymentFeature {
    return this.flags.payment;
  }
}

このマネージャークラスにより、フラグへのアクセスが型安全になり、誤った型のフラグを取得することを防げます。

フラグを更新するメソッドも型安全に実装できます。

typescript// フラグの更新メソッド
export class FeatureFlagManager {
  // ... 前述のコード

  // 新UIフラグの更新
  updateNewUIFeature(feature: NewUIFeature): void {
    this.flags.newUI = feature;
  }

  // 決済フラグの更新
  updatePaymentFeature(feature: PaymentFeature): void {
    this.flags.payment = feature;
  }
}

ステップ 3:UI 描画処理への適用実装

新 UI の描画処理に、型安全なフィーチャーフラグを適用します。

以下の実装では、フラグの状態に応じて異なる UI 描画処理を呼び出しています。

typescript// ui.renderer.ts
import { NewUIFeature } from "./features.types";

// 新UIの描画処理
export function renderUI(feature: NewUIFeature): void {
  if (feature.status === "disabled") {
    // 旧UIを描画
    renderLegacyUI();
    return;
  }

  // この分岐では theme と animationEnabled が確実に存在
  const theme = feature.theme;
  const animated = feature.animationEnabled;

  renderNewUI(theme, animated);
}

新 UI の描画処理の詳細実装です。

typescript// 新UI描画の実装詳細
function renderNewUI(theme: "light" | "dark", animated: boolean): void {
  console.log(`Rendering new UI with theme: ${theme}`);

  if (animated) {
    console.log("Animations enabled");
    // アニメーション付きUI描画処理
  } else {
    // 静的UI描画処理
  }
}

旧 UI の描画処理も定義しています。

typescript// 旧UI描画処理
function renderLegacyUI(): void {
  console.log("Rendering legacy UI");
  // 既存のUI描画処理
}

型の絞り込みにより、themeanimationEnabledへのアクセスが安全に行えることを確認しました。

ステップ 4:決済処理の複雑な状態管理実装

決済機能では、3 つの状態(disabled、beta、enabled)を持ち、それぞれで異なる処理を実装します。

以下は、決済処理のハンドラー実装です。

typescript// payment.handler.ts
import { PaymentFeature } from "./features.types";

// 決済処理のハンドラー
export function processPayment(
  feature: PaymentFeature,
  userId: string,
  amount: number
): void {
  switch (feature.status) {
    case "disabled":
      throw new Error("Payment feature is currently disabled");

    case "beta":
      handleBetaPayment(feature, userId, amount);
      break;

    case "enabled":
      handleEnabledPayment(feature, userId, amount);
      break;
  }
}

beta 状態での決済処理実装です。許可ユーザーのチェックとテストモードの判定を行います。

typescript// beta状態の決済処理
function handleBetaPayment(
  feature: Extract<PaymentFeature, { status: "beta" }>,
  userId: string,
  amount: number
): void {
  // allowedUsers と testMode が確実に存在
  if (!feature.allowedUsers.includes(userId)) {
    throw new Error("User not allowed for beta testing");
  }

  if (feature.testMode) {
    console.log(`[TEST MODE] Processing payment of ${amount}`);
  } else {
    console.log(`[BETA] Processing payment of ${amount}`);
  }
}

enabled 状態での決済処理実装です。プロバイダー選択と実際の決済処理を行います。

typescript// enabled状態の決済処理
function handleEnabledPayment(
  feature: Extract<PaymentFeature, { status: "enabled" }>,
  userId: string,
  amount: number
): void {
  // providers と defaultProvider が確実に存在
  const provider = feature.defaultProvider;
  console.log(`Processing payment via ${provider}`);
  console.log(`Available providers: ${feature.providers.join(", ")}`);
}

この実装では、各状態で必要なパラメータが型レベルで保証されています。

以下の図は、決済機能のフラグが持つ異なる状態間の遷移と、各状態での処理フローを示しています。

mermaidstateDiagram-v2
  [*] --> disabled
  disabled --> beta: ベータ公開
  beta --> enabled: 全体公開
  enabled --> disabled: 緊急停止
  beta --> disabled: 問題発覚

  state beta {
    [*] --> CheckUser
    CheckUser --> AllowedUser: ユーザー確認
    CheckUser --> Reject: 許可なし
    AllowedUser --> TestMode: テストモード実行
  }

  state enabled {
    [*] --> SelectProvider
    SelectProvider --> ProcessPayment: プロバイダー選択
  }

状態遷移が複雑な場合でも、型定義により各状態で必要なパラメータが明確になります。

ステップ 5:初期化とアプリケーションへの組み込み

実際の使用例として、フラグの初期化と利用方法を示します。

以下は、アプリケーション起動時のフラグ初期化コードです。

typescript// app.ts
import { FeatureFlagManager } from "./featureFlags";
import { renderUI } from "./ui.renderer";
import { processPayment } from "./payment.handler";

// フラグの初期設定
const manager = new FeatureFlagManager({
  newUI: {
    status: "enabled",
    theme: "dark",
    animationEnabled: true,
  },
  payment: {
    status: "beta",
    allowedUsers: ["user123", "user456"],
    testMode: true,
  },
});

この設定では、新 UI は有効で、決済機能はベータテスト中という状態を表現しています。

フラグを使った処理の実行コードです。

typescript// フラグを使った処理の実行
export function initializeApp(): void {
  // UIの描画
  const uiFeature = manager.getNewUIFeature();
  renderUI(uiFeature);

  // 決済処理の実行例
  const paymentFeature = manager.getPaymentFeature();
  executePayment(paymentFeature);
}

エラーハンドリングを含めた決済処理の実行実装です。

typescript// エラーハンドリング付き決済実行
function executePayment(paymentFeature: PaymentFeature): void {
  try {
    processPayment(paymentFeature, "user123", 1000);
  } catch (error) {
    console.error("Payment failed:", error);
    // エラー通知処理など
  }
}

型安全性により、実行時エラーを大幅に削減できることを検証で確認しました。

リモート設定サービスとの型安全な統合

実際のプロダクトでは、フラグの設定を外部サービス(LaunchDarkly、Firebase Remote Config など)から取得することが一般的です。

リモート API からのレスポンス型を定義します。外部 API は型が保証されていないため、optional 型で定義しています。

typescript// remote.config.ts
import { FeatureFlags } from "./featureFlags";

// リモートAPIからのレスポンス型
interface RemoteConfigResponse {
  newUI: {
    status: string;
    theme?: string;
    animationEnabled?: boolean;
  };
  payment: {
    status: string;
    allowedUsers?: string[];
    testMode?: boolean;
    providers?: string[];
    defaultProvider?: string;
  };
}

リモートから取得したデータを型安全な形式に変換する関数を実装します。

typescript// リモート設定のパース処理
export function parseRemoteConfig(
  response: RemoteConfigResponse
): FeatureFlags {
  return {
    newUI: parseNewUIFeature(response.newUI),
    payment: parsePaymentFeature(response.payment),
  };
}

新 UI 機能のパース関数です。不正なデータの場合はデフォルト値を返します。

typescript// 新UI機能のパース実装
function parseNewUIFeature(data: RemoteConfigResponse["newUI"]): NewUIFeature {
  if (data.status === "disabled") {
    return { status: "disabled" };
  }

  // enabledの場合は必須パラメータをチェック
  if (
    data.status === "enabled" &&
    data.theme &&
    typeof data.animationEnabled === "boolean"
  ) {
    // 型アサーションで型安全を保つ
    const theme = data.theme as "light" | "dark";
    return {
      status: "enabled",
      theme,
      animationEnabled: data.animationEnabled,
    };
  }

  // 不正なデータの場合はデフォルト値
  return { status: "disabled" };
}

決済機能のパース関数は、より複雑な状態管理に対応しています。

typescript// 決済機能のパース実装
function parsePaymentFeature(
  data: RemoteConfigResponse["payment"]
): PaymentFeature {
  switch (data.status) {
    case "beta":
      if (data.allowedUsers && typeof data.testMode === "boolean") {
        return {
          status: "beta",
          allowedUsers: data.allowedUsers,
          testMode: data.testMode,
        };
      }
      break;

    case "enabled":
      if (data.providers && data.defaultProvider) {
        return {
          status: "enabled",
          providers: data.providers,
          defaultProvider: data.defaultProvider,
        };
      }
      break;
  }

  // 不正なデータの場合はデフォルト値
  return { status: "disabled" };
}

このパース処理により、外部 API の変更や不正なデータからアプリケーションを保護できます。実運用では、このレイヤーでバリデーションとロギングを追加することをおすすめします。

実運用で発生したエラーと対処法

エラー 1: Type 'string' is not assignable to type '"light" | "dark"'

リモート設定から取得した theme 値をそのまま代入しようとした際に、以下のコンパイルエラーが発生しました。

typescript// エラーが発生したコード
function parseNewUIFeature(data: RemoteConfigResponse["newUI"]): NewUIFeature {
  if (data.status === "enabled" && data.theme) {
    return {
      status: "enabled",
      theme: data.theme, // Error: Type 'string' is not assignable to type '"light" | "dark"'
      animationEnabled: data.animationEnabled ?? false,
    };
  }
  return { status: "disabled" };
}

発生条件

  • リモート API から取得したstring型の値を、ユニオン型のプロパティに直接代入した場合

原因

TypeScript はstring型を'light' | 'dark'型に自動的には変換しません。型アサーションまたは型ガードが必要です。

解決方法

型ガードで値をチェックしてから代入するか、型アサーションを使用します。

typescript// 修正後(型ガードを使用)
function parseNewUIFeature(data: RemoteConfigResponse["newUI"]): NewUIFeature {
  if (data.status === "enabled" && data.theme) {
    // 型ガードで有効な値かチェック
    if (data.theme === "light" || data.theme === "dark") {
      return {
        status: "enabled",
        theme: data.theme, // OK: 型が絞り込まれている
        animationEnabled: data.animationEnabled ?? false,
      };
    }
  }
  return { status: "disabled" };
}

型アサーションを使う場合は、値の妥当性を事前に確認してください。

typescript// 型アサーションを使う方法(値の妥当性確認が必要)
const theme =
  data.theme === "light" || data.theme === "dark"
    ? (data.theme as "light" | "dark")
    : "light"; // デフォルト値

解決後の確認

修正後、リモート設定から不正な値('blue'など)が返されても、デフォルト値が使われることを確認しました。

参考リンク

エラー 2: Property 'variant' does not exist on type 'never'

switch 文で全てのケースを網羅していない場合に、以下のコンパイルエラーが発生しました。

typescript// エラーが発生したコード
function handleFeature(flag: AdvancedFeatureFlag): void {
  switch (flag.status) {
    case "disabled":
      console.log("Disabled");
      break;
    // enabledケースの処理を忘れている
  }

  // この時点で flag は never 型になる
  console.log(flag.variant); // Error: Property 'variant' does not exist on type 'never'
}

発生条件

  • switch 文で全ての判別プロパティの値を網羅していない場合
  • TypeScript が「ここには到達しない」と判断し、never型を推論する

原因

型の絞り込みロジックが不完全で、全てのパターンをカバーしていないため。

解決方法 1:default 句を追加

default 句を追加して、全てのケースを網羅します。

typescript// 修正後(default句を追加)
function handleFeature(flag: AdvancedFeatureFlag): void {
  switch (flag.status) {
    case "disabled":
      console.log("Disabled");
      break;
    case "enabled":
      handleEnabledFeature(flag);
      break;
    default:
      // 網羅性チェック:ここに到達したらコンパイルエラー
      const _exhaustiveCheck: never = flag;
      throw new Error(`Unhandled status: ${_exhaustiveCheck}`);
  }
}

解決方法 2:型ガード関数を使用

型ガード関数を使って、明示的に型を絞り込みます。

typescript// 型ガード関数を使った実装
function isEnabled(
  flag: AdvancedFeatureFlag
): flag is Extract<AdvancedFeatureFlag, { status: "enabled" }> {
  return flag.status === "enabled";
}

function handleFeature(flag: AdvancedFeatureFlag): void {
  if (isEnabled(flag)) {
    handleEnabledFeature(flag);
  } else {
    console.log("Disabled");
  }
}

解決後の確認

新しいステータスを追加した際に、switch 文の網羅性チェックが機能し、コンパイルエラーで気づけることを確認しました。

参考リンク

テストコードの実装と型安全性の恩恵

型安全なフィーチャーフラグは、テストも容易になります。

以下は、Jest を使ったテストコードの例です。

typescript// featureFlags.test.ts
import { renderUI } from "./ui.renderer";
import { processPayment } from "./payment.handler";
import { NewUIFeature, PaymentFeature } from "./features.types";

describe("Feature Flags", () => {
  describe("NewUI Feature", () => {
    test("disabled状態では旧UIが描画される", () => {
      const feature: NewUIFeature = { status: "disabled" };
      expect(() => renderUI(feature)).not.toThrow();
    });
  });
});

有効状態のテストケースです。型定義により、必須パラメータの記述漏れを防げます。

typescript// 有効状態のテスト
test("enabled状態では新UIが描画される", () => {
  const feature: NewUIFeature = {
    status: "enabled",
    theme: "dark",
    animationEnabled: true,
  };
  expect(() => renderUI(feature)).not.toThrow();
});

判別可能 union により、テストケースの網羅性も確保しやすくなります。

typescript// すべての状態をテスト
describe("Payment Feature", () => {
  test("disabled状態では決済が拒否される", () => {
    const feature: PaymentFeature = { status: "disabled" };
    expect(() => processPayment(feature, "user123", 1000)).toThrow(
      "Payment feature is currently disabled"
    );
  });

  test("beta状態では許可ユーザーのみ決済可能", () => {
    const feature: PaymentFeature = {
      status: "beta",
      allowedUsers: ["user123"],
      testMode: true,
    };

    // 許可ユーザーは成功
    expect(() => processPayment(feature, "user123", 1000)).not.toThrow();

    // 非許可ユーザーは失敗
    expect(() => processPayment(feature, "user999", 1000)).toThrow(
      "User not allowed for beta testing"
    );
  });
});

enabled 状態のテストケースです。

typescript// enabled状態のテスト
test("enabled状態では全ユーザーが決済可能", () => {
  const feature: PaymentFeature = {
    status: "enabled",
    providers: ["stripe", "paypal"],
    defaultProvider: "stripe",
  };

  expect(() => processPayment(feature, "any-user", 1000)).not.toThrow();
});

型システムがテストケースの漏れを防ぎ、リファクタリング時の安全性も向上します。

テストケースの網羅状況を表にまとめました。

#テスト項目期待動作カバレッジ
1disabled 状態機能が無効基本パス
2beta 状態(許可ユーザー)機能が有効正常系
3beta 状態(非許可ユーザー)エラー発生異常系
4enabled 状態全ユーザーで有効基本パス
5enabled 状態(theme: light)ライトテーマ描画バリエーション
6enabled 状態(theme: dark)ダークテーマ描画バリエーション

実運用では、これらのテストを CI/CD パイプラインに組み込むことで、型の不整合を早期に検出できるようになりました。

条件付き結論:判別可能 union が有効なケース

TypeScript の判別可能 union を活用することで、フィーチャーフラグシステムの型安全性を大幅に向上できました。

この手法により、フラグの状態と必要なパラメータを型レベルで結びつけ、コンパイル時に不整合を検出できるようになります。従来の boolean 型ベースの実装では実行時まで発見できなかったエラーを、コンパイル時に防げることを実プロジェクトで検証しました。

この設計が向いているケース

実務で判別可能 union 設計を採用する際、以下のケースで特に効果を発揮します。

  • フラグに紐づくパラメータが複数ある場合(rolloutPercentage、variant、experimentId など)
  • フラグの状態が 3 つ以上ある場合(disabled、beta、enabled など)
  • チームメンバーが多く、フラグの仕様把握が困難な場合
  • リモート設定サービスと連携し、外部データの型安全性を保ちたい場合
  • テストコードで状態の網羅性を保証したい場合

この設計が向かないケース

一方で、以下のケースでは過度な設計になる可能性があります。

  • フラグが単純な boolean 型で十分な場合(パラメータが不要)
  • プロトタイプや短期的なプロジェクトの場合
  • チームが TypeScript の型システムに不慣れな場合

段階的導入のすすめ

プロジェクトに導入する際は、まず既存のフラグから 1 つずつ判別可能 union へ移行し、段階的に適用範囲を広げることをおすすめします。

最初は複雑な状態を持つフラグ(決済、認証など)から始め、効果を検証してから他のフラグへ展開すると良いでしょう。型定義を整備することで、長期的な保守性と開発効率が向上するはずです。

実運用では、リモート設定のパース処理でバリデーションとロギングを追加し、不正なデータが混入した際の早期検出体制を整えることも重要です。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;