T-CREATOR

TypeScript 型安全なフィーチャーフラグ設計:判別可能共用体で運用事故を防ぐ

TypeScript 型安全なフィーチャーフラグ設計:判別可能共用体で運用事故を防ぐ

フィーチャーフラグは新機能のロールアウトや A/B テストに欠かせない仕組みですが、実装を間違えると本番環境で予期せぬ挙動を引き起こす可能性があります。特に複数の機能バリエーションを管理する際、型の不整合が原因で「このフラグが有効なのに、必要なパラメータが渡されていない」といった運用事故が発生しがちです。

TypeScript の判別可能共用体(Discriminated Unions)を活用すれば、フィーチャーフラグの状態とそれに紐づく設定値を型レベルで結びつけられます。これにより、コンパイル時に不整合を検出でき、安全で保守性の高いフィーチャーフラグシステムを構築できるのです。

背景

フィーチャーフラグの役割

フィーチャーフラグは、コードをデプロイせずに機能のオン・オフを切り替える仕組みです。新機能を段階的にリリースしたり、特定のユーザーグループだけに公開したりする際に活用されます。

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

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

#項目説明
1カナリアリリース一部のユーザーにのみ新機能を公開し、問題がないか検証する
2A/B テスト複数のバリエーションを用意し、ユーザーごとに異なる体験を提供する
3緊急停止問題が発生した際、デプロイせずに即座に機能を無効化する
4段階的ロールアウトパーセンテージ指定で徐々にユーザー範囲を拡大する

従来の実装方法と課題

一般的なフィーチャーフラグの実装では、boolean 値や文字列を使って機能の有効・無効を判定します。

typescript// 従来の実装例:シンプルなboolean型
interface FeatureFlags {
  enableNewUI: boolean;
  enableBetaFeature: boolean;
}

しかし、このシンプルな実装では以下のような課題が発生します。

typescript// フラグとパラメータの関連性が型で保証されない
const flags = {
  enableNewUI: true,
  // rolloutPercentageが必要だが、型で強制されない
};

function renderUI(flags: FeatureFlags) {
  if (flags.enableNewUI) {
    // rolloutPercentageが存在するか実行時まで分からない
    const percentage = (flags as any).rolloutPercentage;
  }
}

型の不整合により、以下のような運用事故が発生する可能性があります。

  • フラグが有効なのに必要なパラメータが渡されていない
  • 無効なフラグに対して不要なパラメータがセットされている
  • コードレビューで気づけず、本番環境でエラーが発生する

課題

型安全性の欠如がもたらす問題

従来のフィーチャーフラグ実装では、フラグの状態と関連パラメータが型レベルで結びついていません。

typescript// 問題のある実装例
interface UnsafeFeatureConfig {
  enabled: boolean;
  variant?: 'A' | 'B' | 'C';
  rolloutPercentage?: number;
  experimentId?: string;
}

この実装では、全てのプロパティがオプショナルになっており、以下のような不整合が型チェックをすり抜けてしまいます。

typescript// コンパイルエラーにならないが、実行時に問題が発生する例
const config1: UnsafeFeatureConfig = {
  enabled: true,
  // variantが必要なのに指定されていない
};

const config2: UnsafeFeatureConfig = {
  enabled: false,
  variant: 'A', // 無効なのにvariantが指定されている
  rolloutPercentage: 50, // 無効なのにrolloutPercentageも指定
};

このような実装では、コンパイル時にエラーが発生せず、実行時に初めて問題が明らかになります。

実行時エラーのリスク

型の不整合が原因で、本番環境で以下のようなエラーが発生します。

typescript// 実行時エラーの例
function getVariant(config: UnsafeFeatureConfig): string {
  if (config.enabled) {
    // variantが存在しない場合、undefinedになる
    return config.variant.toUpperCase(); // TypeError: Cannot read property 'toUpperCase' of undefined
  }
  return 'default';
}
#エラーの種類発生条件影響範囲
1TypeError必須パラメータが未定義ユーザー体験の破綻
2ロジックエラー無効なフラグで処理が実行されるデータの不整合
3セキュリティリスクアクセス制御の判定ミス未公開機能の漏洩
mermaidflowchart TD
  start["フラグ取得"] --> check{enabled?}
  check -->|true| getParam["パラメータ取得"]
  getParam --> exists{パラメータ<br/>存在?}
  exists -->|Yes| process["正常処理"]
  exists -->|No| error["TypeError発生"]
  check -->|false| skip["処理スキップ"]
  error -->|"実行時エラー"| crash["アプリケーション<br/>クラッシュ"]

この図は、型安全性が欠如した実装で実行時エラーが発生するまでのフローを示しています。パラメータの存在確認が実行時まで行われないため、エラーが検出されるのが遅れます。

保守性の低下

フィーチャーフラグが増えるにつれて、どのフラグにどのパラメータが必要か把握するのが困難になります。

typescript// 10個以上のフラグと複雑な依存関係
interface ComplexFeatureFlags {
  enableFeatureA: boolean;
  featureAConfig?: { timeout: number };
  enableFeatureB: boolean;
  featureBVariant?: 'v1' | 'v2';
  enableFeatureC: boolean;
  featureCExperimentId?: string;
  // ... さらに増え続ける
}

この状態では、新しいメンバーがコードを理解するのに時間がかかり、変更時のミスも増加します。

解決策

判別可能共用体(Discriminated Unions)とは

判別可能共用体は、TypeScript の型システムを活用して、複数の異なる型を 1 つの型として扱う手法です。共通のプロパティ(判別プロパティ)を持つことで、TypeScript が型を自動的に絞り込みます。

typescript// 判別可能共用体の基本構造
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number };

kindプロパティが判別プロパティとして機能し、TypeScript はこの値に基づいて型を推論します。

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;
  }
}

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

型安全なフィーチャーフラグの設計

判別可能共用体を使って、フィーチャーフラグの各状態を個別の型として定義します。

typescript// 基本的な型定義:判別プロパティに enabled を使用
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
};

const invalidConfig2: FeatureFlag = {
  enabled: false,
  variant: 'A', // Error: Property 'variant' does not exist on type '{ enabled: false }'
};

逆に、正しい定義はコンパイルエラーなく作成できます。

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

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

型ガードによる安全なアクセス

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

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
  }
}

この仕組みにより、実行時エラーを防げます。

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

この図は、判別可能共用体を使った場合の型の絞り込みと安全性の仕組みを示しています。コンパイル時に型が保証されるため、実行時エラーが発生しません。

複数パターンへの拡張

実際のプロジェクトでは、さらに複雑なフィーチャーフラグが必要になることがあります。

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を判別プロパティとして使い、それぞれの状態で必要なパラメータが異なります。

typescript// パターンごとの処理
function processFeature(flag: AdvancedFeatureFlag): void {
  switch (flag.status) {
    case 'disabled':
      console.log('Feature is disabled');
      break;
    case 'enabled':
      // さらに mode で分岐
      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;
      }
      break;
  }
}

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

#ステータスモード必須パラメータ
1disabled-なし
2enabledsimpleなし
3enabledab-testvariants, experimentId
4enabledrolloutpercentage, targetAudience

具体例

実践的なフィーチャーフラグシステムの実装

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

ステップ 1:型定義の作成

まず、フィーチャーフラグの型を定義します。

typescript// features.types.ts
// フィーチャーフラグの基本型定義
export type FeatureFlagStatus =
  | 'disabled'
  | 'enabled'
  | 'beta'
  | 'deprecated';

// 個別機能ごとの型定義
export type NewUIFeature =
  | { status: 'disabled' }
  | {
      status: 'enabled';
      theme: 'light' | 'dark';
      animationEnabled: boolean;
    };

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;
  }

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

  getPaymentFeature(): PaymentFeature {
    return this.flags.payment;
  }
}

このマネージャークラスにより、フラグへのアクセスが型安全になります。

ステップ 3:フラグを使った処理の実装

実際にフラグを使って機能を切り替える処理を実装します。

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);
}

型の絞り込みにより、themeanimationEnabledへのアクセスが安全に行えます。

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

function renderLegacyUI(): void {
  console.log('Rendering legacy UI');
}

ステップ 4:決済機能の実装

より複雑な決済機能のフラグ処理を実装します。

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':
      // 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}`
        );
      }
      break;

    case 'enabled':
      // providers と defaultProvider が確実に存在
      const provider = feature.defaultProvider;
      console.log(`Processing payment via ${provider}`);
      break;
  }
}

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

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();
  try {
    processPayment(paymentFeature, 'user123', 1000);
  } catch (error) {
    console.error('Payment failed:', error);
  }
}

型安全性により、実行時エラーを大幅に削減できます。

リモート設定との統合

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

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),
  };
}

個別のパース関数を実装し、型の整合性を保証します。

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

  if (
    data.status === 'enabled' &&
    data.theme &&
    typeof data.animationEnabled === 'boolean'
  ) {
    return {
      status: 'enabled',
      theme: data.theme as 'light' | 'dark',
      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 の変更や不正なデータからアプリケーションを保護できます。

テストコードの実装

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

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' };
      // renderUI関数が型安全に呼び出せる
      expect(() => renderUI(feature)).not.toThrow();
    });
  });
});

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

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

  test('beta状態では許可ユーザーのみ決済可能', () => {
    const feature: PaymentFeature = {
      status: 'beta',
      allowedUsers: ['user123'],
      testMode: true,
    };
    expect(() =>
      processPayment(feature, 'user123', 1000)
    ).not.toThrow();
    expect(() =>
      processPayment(feature, 'user999', 1000)
    ).toThrow();
  });

  test('enabled状態では全ユーザーが決済可能', () => {
    const feature: PaymentFeature = {
      status: 'enabled',
      providers: ['stripe', 'paypal'],
      defaultProvider: 'stripe',
    };
    expect(() =>
      processPayment(feature, 'any-user', 1000)
    ).not.toThrow();
  });
});

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

#テスト項目期待動作カバレッジ
1disabled 状態機能が無効基本パス
2beta 状態(許可ユーザー)機能が有効正常系
3beta 状態(非許可ユーザー)エラー発生異常系
4enabled 状態全ユーザーで有効基本パス

まとめ

TypeScript の判別可能共用体を活用することで、フィーチャーフラグシステムの型安全性を大幅に向上できます。この手法により、フラグの状態と必要なパラメータを型レベルで結びつけ、コンパイル時に不整合を検出できるようになりました。

従来の実装では実行時まで発見できなかったエラーを、コンパイル時に防ぐことができます。これにより、本番環境での運用事故リスクが大幅に減少し、開発者の認知負荷も軽減されます。

型安全なフィーチャーフラグ設計は、複雑な機能のロールアウトや A/B テストを安全に実行するための強力な基盤となります。チームの規模が大きくなるほど、この設計の恩恵は大きくなるでしょう。

プロジェクトに導入する際は、まず既存のフラグから 1 つずつ判別可能共用体へ移行し、段階的に適用範囲を広げることをおすすめします。型定義を整備することで、長期的な保守性と開発効率が向上するはずです。

関連リンク