🐹

112通りの複雑な表示を、いかに保守性の高い実装にできるか挑戦した話

に公開

はじめに

こんにちは、株式会社ドワンゴでニコニコ生放送のフロントエンド開発を担当している misuken です。

みなさんのプロジェクトには「条件分岐が複雑すぎて仕様と実装が本当に一致しているのかわからない」なんて機能はありませんか?

前回の記事 誰も把握できなくなった超難解な仕様を、リバースエンジニアリングで112通りと断定した話 では、極めて複雑なGateという機能の仕様を、スプレッドシートによる仕様整理で劇的に改善した取り組みをご紹介しました。

https://zenn.dev/misuken/articles/32807daad601d0

今回は、その実装編。前回整理した112通りの複雑な表示を、いかに保守性の高い実装にしたのかがテーマです。

この記事では、TypeScriptの型システムを活用し、どのように保守性の高い実装を実現したのか、実際のコードを交えながら紹介していきます。

TL;DR

複雑な仕様を保守しやすい実装するためのアプローチ:

  • 日本語での型定義や実装により、ドメイン知識とコードの距離を最小化
  • 仕様書のパターン、コンテキスト、番組種別のフローを可能な限りそのまま関数として実装し、仕様とコードを1対1で対応
  • 状態の組み合わせを型で厳格に管理し、実装時に不正な状態を作れないよう設計
  • 型だけでは守りきれないわずかな隙も刈り取って安全性を向上
  • Storybookで全パターンを、仕様書の通り数と照合しやすくして実装漏れを防止

結果: 間違えられる余地がほとんどない、仕様の表記ほぼそのままの安全でわかりやすい実装が完成

定義周り

スプレッドシートで整理した仕様を、TypeScriptの型で表現していきます。

ParamsTemplate: 全状態変数の定義

まずはじめに、仕様書の各分岐条件から拾ってきた状態を ParamsTemplate という1つの型に定義しました。

特筆すべき点として、このコードの多くは日本語で書かれています。
複雑な仕様を実現する際、日本人チームで開発するプロジェクトであれば、日本語で書くことでわかりやすく簡潔になる場面があるためです。

保守性を高めるうえで "いかに仕様と実装を一致させるか" という点では日本語での実装が思いのほかうまくいったように思います。

/**
 * 条件分岐で使用する値のテンプレート
 *
 * Gate仕様のスプレッドシートの条件分岐に使用される値を全て網羅する
 */
export type ParamsTemplate = {
  // 基本ステータス
  番組種別: "ユーザー生放送" | "CH系" | "公式";
  番組状態: "開始前" | "放送中" | "終了後";
  TS状態: "公開前" | "公開期間中" | "公開終了後";

  // ユーザー関連
  ロール: "視聴者" | "放送者";
  /**
   * プレミアム会員ならtrue、一般会員や非ログインならfalse。
   */
  プレミアム会員: boolean;

  // 制限関連
  国別制限: "ja" | "en" | "zh" | undefined;
  /**
   * タイムシフトが有効な設定であれば true。
   */
  TS有効: boolean;
  /**
   * ユーザー生放送と公式番組において、認証が必要な状態を表す。
   */
  要認証: boolean;

  // 視聴権関連
  /**
   * TSを視聴する権利を有しているかどうか。
   */
  TS視聴権: boolean;

  // 公式有料の視聴権関連
  /**
   * 公式の有料番組における概念。
   * この番組がシリアルコード適用 or チケット購入のどの条件を満たせば視聴が可能かを表す。
   */
  視聴条件: "SC" | "TK" | "TKorSC";

  // ... 他
};

PT: Paramsオブジェクトを生成する型

ParamsTemplate から必要な部分だけを利用するシーンが多いので、指定したキーのみを持つオブジェクト型を得るための PT も定義しました。

type PTKey = keyof ParamsTemplate;
/**
 * 宣言的にParamsTemplateの一部を抜き出すための型
 */
export type PT<T extends PTKey> = Pick<ParamsTemplate, T>;

// 使用例: B1パターンで必要なParamsオブジェクトの型
// type Pattern_B1_Params = PT<"TS有効" | "ロール" | "TS予約済み">;

パーツの定義

使用するパーツも全て型として用意しました。

文言もそのまま型として定義することで、処理側でも可読性と安全性を両立する実装になりました。

// 引数も必要なパーツはオブジェクト型で定義
export type 国別制限 = { type: "国別制限"; language: "ja" | "en" | "zh" };
export type タイムシフト予約ボタン = { type: "タイムシフト予約ボタン"; reserved: boolean };
// ...その他の定義が数個

// 1️⃣ 見出し
type TitleType =
  | "ニコニコにログインしてください"
  // 〜 省略 〜
  | 国別制限;

// 2️⃣ 説明
type DescriptionType =
  | "次回の放送をお楽しみください"
  // 〜 省略 〜
  | "公開期間を過ぎると、視聴の途中であっても番組を見ることができなくなります";

// 3️⃣ メインボタン
type MainButtonType =
  | "合い言葉入力ボタン"
  // 〜 省略 〜
  | タイムシフト予約ボタン;

// ... 他の部位も同様に定義

ビルダー

表示条件の組み立て用に、シンプルなビルダーを用意しました。
引数の型はパーツの定義で用意したものに限定されるので、補完も効きますし、文言を間違える心配もありません。

ビルダーは build() すると Condition オブジェクトを返します。呼び出し元では、それをpropsに変換し、Reactコンポーネントに渡します。

builder._1_title("放送開始までしばらくお待ちください");
builder._3_mainButton({ type: "タイムシフト予約ボタン", reserved: true });
// build の結果、以下のオブジェクトが生成されるだけの単純なもの
// { title: "放送開始までしばらくお待ちください", mainButton: { type: "タイムシフト予約ボタン", reserved: true } }
builder.build();

具体的な実装

続いて、パターン、コンテキスト、番組種別に該当する単位の実装です。

  • パターン: T1 TF などに相当する部品
  • コンテキスト: 国別制限 TS無効 要認証 などのセクションに相当するフロー
  • 番組種別: ユーザー生放送 CH系 公式 などの縦列に相当するフロー

パターンの実装

前回の記事で判明した17種類のパターンのうち "B1" を例にします。

仕様書におけるパターンの部分

実装は、仕様をそのまま書き起こした構造になるよう工夫しました。
パターンは builder に値をセットするだけの責務を持ちます。

import type { Builder, PT } from "../definitions";

// このパターンが依存する状態は3つ
type Pattern_B1_Params = PT<"TS有効" | "ロール" | "TS予約済み">;
export function pattern_B1(builder: Builder, params: Pattern_B1_Params) {
  builder._1_title("放送開始までしばらくお待ちください");
  if (params.TS有効 && params.ロール === "視聴者") {
    builder._3_mainButton({ type: "タイムシフト予約ボタン", reserved: params.TS予約済み });
  }
}

コンテキストの実装

コンテキストの実装は仕様書の "要認証" を例にします。

ユーザー生放送とCH系の要認証。

ユーザー生放送とCH系の要認証コンテキスト

公式の要認証。

公式の要認証コンテキスト

コンテキストも仕様をそのまま書き起こした構造になっていて、仕様との一致を簡単に確認できます。
コンテキストは、条件に一致すれば builder.build()Condition を返し、一致しなければ渡された関数を実行してフローを継続します。

export function context_要認証(builder: Builder, params: 要認証Params, next: () => Condition | null): Condition | null {
  // コンテキストは複数使用される場合、実行順序を守る必要があります。
  // そのため、間違った順序で実行された場合はエラーとなり、テストで気付けるようにしています。
  builder.setLayerLevel(LAYER_LEVEL_要認証);

  if (params.要認証) {
    switch (params.番組種別) {
      case "ユーザー生放送":
        // P1パターン (ここでしか使用していないので、関数化していないパターン)
        builder._1_title("視聴するには合い言葉の入力が必要です");
        builder._3_mainButton("合い言葉入力ボタン");
        return builder.build();
      case "公式":
        // L1パターン (ここでしか使用していないので、関数化していないパターン)
        builder._1_title("ニコニコにログインしてください");
        builder._3_mainButton("ログイン導線");
        return builder.build();
      default:
        throw new TypeError(`Unexpected 番組種別: ${params satisfies never}`);
    }
  }
  return next();
}

番組種別は3種類ですが、switchにCH系のcaseが無いのは、後述するparamsの型が仕様に対して忠実に作られているためです。
あり得ない状態が存在しないよう、型レベルで最適化されています。

番組種別ごとのフローの実装

番組種別ごとのフローの実装は仕様書の "ユーザー生放送" を例にします。

ユーザー生放送の番組種別フロー

番組種別ごとのフローも仕様をそのまま書き起こした構造になっていて、仕様との一致が簡単に確認できます。

仕様を読み取った例と比較する

スプレッドシートの仕様を読み取っていくと、以下のように読めるはずです。これを実際のコードと見比べてみると、 import の部分から写像になっていることが良くわかります。

  • ユーザー生放送では 国別制限 TS無効 要認証 のコンテキストを使用します
  • コンテキスト外では B1 T41 T2 TF TR のパターンを使用します
  • 全体適用のコンテキスト 国別制限 に引っかかったら R1 を表示します
    • 手前の条件に一致しなかったら以下に続きます
    • 番組状態が開始前
      • 要認証 に引っかかったら P1 を表示します
      • 手前の条件に一致しなかったら B1 を表示します
    • 番組状態が番組放送中
      • 要認証 に引っかかったら P1 を表示します
      • 手前の条件に一致しなかったらプレーヤーを表示します
    • 番組状態が番組終了後
      • TS状態がTS公開期間中
        • TS無効 に引っかかったら T1 TF TR の組み合わせを表示します
        • 要認証 に引っかかったら P1 を表示します
        • 非プレミアム会員 & TS視聴権なし & 無料番組 の条件に一致したら T41 を表示します
        • 手前の条件に一致しなかったらプレーヤーを表示します
      • TS状態がTS公開期間終了後
        • TS無効 に引っかかったら T1 TF TR の組み合わせを表示します
        • 手前の条件に一致しなかったら T2 TF TR の組み合わせを表示します
import type { Builder, Condition, ユーザー生放送Params } from "../definitions";
import { context_国別制限, context_TS無効, context_要認証 } from "../patterns/context-pattern";
import { pattern_B1 } from "../patterns/pattern-B1";
import { pattern_T2 } from "../patterns/pattern-T2";
import { pattern_T41 } from "../patterns/pattern-T41";
import { pattern_TF } from "../patterns/pattern-TF";
import { pattern_TR } from "../patterns/pattern-TR";

export function resolve_ユーザー生放送(builder: Builder, params: ユーザー生放送Params): Condition | null {
  return context_国別制限(builder, params, () => {
    switch (params.番組状態) {
      case "開始前":
        return context_要認証(builder, params, () => {
          pattern_B1(builder, params);
          return builder.build();
        });
      case "放送中":
        return context_要認証(builder, params, () => {
          return null; // プレーヤーを表示
        });
      case "終了後": {
        switch (params.TS状態) {
          // ユーザー生放送に公開前の状態は存在しない
          case "公開期間中":
            return context_TS無効(builder, params, () => {
              return context_要認証(builder, params, () => {
                // ユーザー生放送は常に無料番組なので、判定は省略されています
                if (!params.プレミアム会員 && !params.TS視聴権) {
                  pattern_T41(builder);
                  return builder.build();
                }
                return null; // プレーヤーを表示
              });
            });
          case "公開終了後":
            return context_TS無効(builder, params, () => {
              pattern_T2(builder);
              pattern_TF(builder, params);
              pattern_TR(builder, params);
              return builder.build();
            });
          default:
            throw new TypeError(`Unexpected TS状態: ${params satisfies never}`);
        }
      }
      default:
        throw new TypeError(`Unexpected 番組状態: ${params satisfies never}`);
    }
  });
}

params の型も細部まで正確に効いていて、網羅チェックも万全。フロー自体が型安全になっていると言えます。

フローの始まり

最後にフローが開始する最上位の関数。処理はこれで全て、とてもシンプルな作りです。
スコープの単位も各レイヤーごとに対応させたことで、それぞれの責務が小さく、境界のはっきりしたコードに仕上がりました。

export function resolveCondition(params: AllParams): Condition | null {
  const builder = new Builder();
  switch (params.番組種別) {
    case "ユーザー生放送":
      return resolve_ユーザー生放送(builder, params);
    case "CH系":
      return resolve_CH系(builder, params);
    case "公式":
      return resolve_公式(builder, params);
    default:
      throw new TypeError(`Unexpected 番組種別: ${params satisfies never}`);
  }
}

安全なParams型の構築

記事中に度々登場している params という引数の型について説明していきます。

前回の記事で紹介した通り、Gateを正しく表示するには、24種類の状態を112通りに合わせて適切に渡す必要があります。
そこで、日本語の型を使い、論理的に記述するだけで正確な型になる型システムを作りました。

番組種別x番組状態の組み合わせごとに、以下を組み立てます。

  • コンテキストで使用する状態
  • 条件分岐で使用する状態
  • 有料・無料番組で必要な状態

これにより、正確かつ効率的で把握しやすい型定義が実現しました。

  • 不要な状態を持てない: 各状況において使わない状態は型に含まれないため、誤って参照されることがない
  • 必要な状態が漏れない: 各組み合わせで必要な状態がすべて要求されるため、渡し漏れは起こり得ない
  • Narrowingによる自動推論: 番組種別番組状態 で分岐すると、TypeScriptが自動的に型を絞り込んでくれる

型定義でキーが不足したり間違っていれば、パターンやコンテキスト側がピンポイントで型エラーになるため、変更やリファクタの際も安心です。

// ユーザー生放送のパラメータ型
export type ユーザー生放送Params =
  // 全体共通の型引数: <番組種別, ロール, 番組状態別の詳細なParams>
  | 全体共通<"ユーザー生放送", "放送者", ユーザーCH系共通>
  | 全体共通<"ユーザー生放送", "視聴者", ユーザーCH系共通>;

// CH系のパラメータ型
export type CH系Params =
    全体共通<"CH系", "視聴者", ユーザーCH系共通<有料番組の場合<"番組視聴権" | "番組視聴権獲得条件"> | 無料番組の場合>>;

// 公式のパラメータ型
export type 公式Params =
    全体共通<"公式", "視聴者",
  // 番組状態別の型引数: <使用するコンテキスト, 追加で必要な状態のキー, 必要に応じて詳細なParams>
  | 開始前<要認証Params, "TS有効" | "TS予約済み", 公式_開始前_有料無料>
  | 放送中<要認証Params, never, 公式_放送中_有料無料>
  | TS公開前<TS無効Params, "TS予約済み">
  | TS公開期間中<TS無効Params & 要認証Params, "TS視聴権" | "TS視聴期限切れ" | "視聴時間制限" | "TS視聴開始済み", 公式_TS公開期間中_有料無料>
  | TS公開終了後<TS無効Params, "フォロー済み">
>;

// 全パラメータの統合型
export type AllParams = ユーザー生放送Params | CH系Params | 公式Params;
全ての定義が気になる方はこちらをご覧ください
// 要認証の定義(番組種別によって差があるので、それぞれで定義)
type 要認証不要 = { 要認証?: false };
export type 要認証Params =
  | ({ 番組種別: "ユーザー生放送" } & PT<"要認証" | "ロール">)
  | ({ 番組種別: "CH系" } & 要認証不要)
  | ({ 番組種別: "公式" } & PT<"要認証" | "ロール">);

// TS無効の定義(番組種別に加えて、ロールによっても差があるので、それぞれで定義)
type TS無効共通 = PT<"TS有効" | "フォロー済み">;
export type TS無効Params =
  | ({ 番組種別: "ユーザー生放送"; ロール: "放送者" } & TS無効共通)
  | ({ 番組種別: "ユーザー生放送"; ロール: "視聴者" } & TS無効共通 & PT<"放送リクエスト数">)
  | ({ 番組種別: "CH系"; ロール: "視聴者" } & TS無効共通)
  | ({ 番組種別: "公式"; ロール: "視聴者" } & TS無効共通);

// 各番組状態別の基本定義
// C には TS無効 要権限 といったGate仕様のスプレのコンテキストに相当する部分を指定します。
// K には、このスコープ全体で必要な ParamsTemplate のキーを指定します。(不要で D を指定したい場合は never を指定してください)
// D には、分岐を含むような詳細なパターンを指定します。
type 開始前<C, K extends PTKey = never, D = {}> = { 番組状態: "開始前" } & C & PT<K> & D;
type 放送中<C, K extends PTKey = never, D = {}> = { 番組状態: "放送中" } & C & PT<K> & D;
type 終了後<TS状態, C, K extends PTKey = never, D = {}> = { 番組状態: "終了後"; TS状態: TS状態 } & C & PT<K> & D;
type TS公開前<C, K extends PTKey = never, D = {}> = 終了後<"公開前", C, K, D>;
type TS公開期間中<C, K extends PTKey = never, D = {}> = 終了後<"公開期間中", C, K, D>;
type TS公開終了後<C, K extends PTKey = never, D = {}> = 終了後<"公開終了後", C, K, D>;

// 条件別用の定義
type 有料番組の場合<K extends PTKey = never> = { 有料番組: true } & PT<K>;
type 無料番組の場合<K extends PTKey = never> = { 有料番組: false } & PT<K>;

// 全体共通で必要な状態
// D には、分岐を含むような詳細なパターンを指定します。
type 全体共通<番組種別 extends ParamsTemplate["番組種別"], ロール extends ParamsTemplate["ロール"], D> = 
    { 番組種別: 番組種別; ロール: ロール } & PT<"国別制限"> & D;

// ユーザー生放送とCH系で各番組状態ごとに必要な状態
type ユーザーCH系共通<D = {}> =
  | 開始前<要認証Params, "TS有効" | "TS予約済み", D>
  | 放送中<要認証Params>
  | TS公開期間中<TS無効Params & 要認証Params, "プレミアム会員" | "TS視聴権", D>
  | TS公開終了後<TS無効Params, "フォロー済み">;

// 公式(有料/無料)の各番組状態ごとに必要な状態
type 公式_開始前_有料無料 = 有料番組の場合<"ちら見せ" | "購入済み" | "視聴条件"> | 無料番組の場合;
type 公式_放送中_有料無料 = 有料番組の場合<"ちら見せ" | "購入済み" | "視聴条件"> | 無料番組の場合;
type 公式_TS公開期間中_有料無料 = 有料番組の場合<"視聴条件"> | 無料番組の場合<"プレミアム会員" | "TS予約不要">;

その他の工夫

ここまででメインの実装の大部分は完成しています。さらに工夫は続きます。

Builderクラスの安全機構

Builderクラスには以下のような安全機構を実装し、万が一早期リターン漏れなどでフローを間違えて実装しても、テストが落ちて気付けるようにしています。

  • 同じ番号のパーツを2回設定したらエラー
  • パーツの登録順が正しくなかったらエラー (1️⃣ タイトル を設定せずに 2️⃣ 説明 を追加等)

型だけでは守れない、隙の生まれそうなリスクもしっかり刈り取ることが大切です。

Storybookにおける全パターンの網羅

Storybookにおいて、全パターンを正確に漏れなく定義するのは一見大変そうに感じますが、Storybookのパターンも仕様書そのままに書ければ何も難しいことはありません。

ここでは、ユーザー生放送の番組終了後のTS公開期間中のパターンを例にします。

Storybookのパターン定義例

TS無効のコンテキスト部分はこちら。

TS無効のコンテキスト

仕様書の条件のセルをコピペでコメントとして並べ、通り数を意識しながらコメントの内容を OBJ というヘルパー(オブジェクトを結合するだけの関数)に渡せば完了です。

引数に渡している TS無効 といった定数は const TS無効 = { TS有効: false } as const; と定義された単純な ValueObject になっており、ValueObject を1行に並べてコメントとの一致を確認しやすくする点も意識しています。

export const config = {
  //------------------------
  // ユーザー生放送
  //------------------------
  "【ユーザー生放送】 番組開始前": generateConfigItem({ /* 省略 */ }),
  "【ユーザー生放送】 番組放送中": generateConfigItem({ /* 省略 */ }),
  "【ユーザー生放送】 番組終了後(TS公開前)": generateConfigItem({ /* 省略 */ }),
  "【ユーザー生放送】 番組終了後(TS公開期間中)": generateConfigItem({
    baseParams: OBJ(ユーザー生放送, 終了後TS公開期間中, { 放送リクエスト数: 1000 }),
    patterns: [
      // TS無効 (4通り)
      OBJ(TS無効, 視聴者, 未フォロー),
      OBJ(TS無効, 視聴者, フォロー済み),
      OBJ(TS無効, 放送者, 未フォロー),
      OBJ(TS無効, 放送者, フォロー済み),
      // TS有効 & 要認証 (1通り)
      OBJ(TS有効, 要認証),
      // TS有効 & 認証通過 & 非プレミアム会員 & TS視聴権なし & 無料番組 (1通り)
      OBJ(TS有効, 認証通過, 非プレミアム会員, TS視聴権なし, 無料番組), // ユーザー生放送の有料番組はない
      // TS有効 & 認証通過 & (プレミアム会員 or TS視聴権あり) (3通り)
      OBJ(TS有効, 認証通過, 非プレミアム会員, TS視聴権あり),
      OBJ(TS有効, 認証通過, プレミアム会員, TS視聴権なし),
      OBJ(TS有効, 認証通過, プレミアム会員, TS視聴権あり),
      ...国別制限_1通り,
    ],
  }),

1行でわかりやすくという意識は、ParamsTemplate視聴条件 の定義が "SC" | "TK" | "TKorSC" となっていたところにも現れています。ValueObject も簡潔でわかりやすく、まとまりや表記を工夫することで、徹底して隙を無くしました。

 "【公式】 番組終了後(TS公開期間中)": generateConfigItem({
    baseParams: OBJ(公式, 終了後TS公開期間中, 視聴者),
    patterns: [
      // TS無効 (2通り)
      OBJ(TS無効, 未フォロー),
      OBJ(TS無効, フォロー済み),
      // TS有効 & 要認証 (1通り)
      // ...
      // TS有効 & 認証通過 & TS視聴権あり & 視聴時間制限あり & TS視聴開始済み & TS視聴期限内 (1通り)
      OBJ(TS有効, 認証通過, TS視聴権あり, 視聴時間制限あり_視聴開始済み_期限内),
      // TS有効 & 認証通過 & TS視聴権あり & 視聴時間制限あり & TS視聴開始済み & TS視聴期限切れ (4通り)
      OBJ(TS有効, 認証通過, TS視聴権あり, 視聴時間制限あり_視聴開始済み_期限切れ, 無料番組),
      OBJ(TS有効, 認証通過, TS視聴権あり, 視聴時間制限あり_視聴開始済み_期限切れ, 有料番組, 視聴条件_SC),
      OBJ(TS有効, 認証通過, TS視聴権あり, 視聴時間制限あり_視聴開始済み_期限切れ, 有料番組, 視聴条件_TK),
      OBJ(TS有効, 認証通過, TS視聴権あり, 視聴時間制限あり_視聴開始済み_期限切れ, 有料番組, 視聴条件_TKorSC),
      // もしも工夫せずにそのまま書いた場合は自動フォーマットで以下のようになりますが、
      // 改行されただけで一気に視認性が落ち、間違いを見落とす隙が生まれます。
      // OBJ(
      //   TS有効,
      //   認証通過,
      //   TS視聴権あり,
      //   視聴時間制限あり,
      //   TS視聴開始済み,
      //   TS視聴期限切れ,
      //   有料番組,
      //   視聴条件_チケットかシリアルコード
      // ),
      ...国別制限_1通り,
    ],
  }),
その他の主なValueObjectの定義が気になる方はこちらをご覧ください
// 基本情報
const ユーザー生放送 = { 番組種別: "ユーザー生放送" } as const;
const CH= { 番組種別: "CH系" } as const;
const 公式 = { 番組種別: "公式" } as const;
const 開始前 = { 番組状態: "開始前" } as const;
const 放送中 = { 番組状態: "放送中" } as const;
const 終了後TS公開前 = { 番組状態: "終了後", TS状態: "公開前" } as const;
const 終了後TS公開期間中 = { 番組状態: "終了後", TS状態: "公開期間中" } as const;
const 終了後TS公開終了後 = { 番組状態: "終了後", TS状態: "公開終了後" } as const;

// 各種条件
const 視聴者 = { ロール: "視聴者" } as const;
const 放送者 = { ロール: "放送者" } as const;
const 非プレミアム会員 = { プレミアム会員: false } as const;
const プレミアム会員 = { プレミアム会員: true } as const;
// ...
const 視聴開始前 = { 視聴開始済み: false } as const;
const 視聴開始済み = { 視聴開始済み: true } as const;
const 視聴時間制限なし = { 視聴時間制限: false } as const;
const 視聴時間制限あり = { 視聴時間制限: { 視聴可能期間_秒: 12 * 60 * 60 + 5 * 60 } } as const; // 12時間5分
const 視聴時間制限あり_視聴開始済み_期限内 = { ...視聴時間制限あり, ...視聴開始済み, 視聴期限切れ: false } as const;
const 視聴時間制限あり_視聴開始済み_期限切れ = { ...視聴時間制限あり, ...視聴開始済み, 視聴期限切れ: true } as const;

// 定型のパターン
const 存在しないパターン_0通り = [];
const プレーヤーが表示される_1通り = [{}];
const 国別制限_1通り = [国別制限_日本語] as const;

type T = Partial<ParamsTemplate>;
// オブジェクトを結合するヘルパー
function OBJ<const T1 extends T>(t1: T1): T1;
function OBJ<const T1 extends T, const T2 extends T>(...arr: [T1, T2]): T1 & T2;
// ...
function OBJ<
  const T1 extends T,
  const T2 extends T,
  const T3 extends T,
  const T4 extends T,
  const T5 extends T,
  const T6 extends T,
  const T7 extends T,
  const T8 extends T,
  const T9 extends T,
>(...arr: [T1, T2?, T3?, T4?, T5?, T6?, T7?, T8?]): T1 & T2 & T3 & T4 & T5 & T6 & T7 & T8 & T9 {
  return Object.assign({}, ...arr);
}

作成したconfigを元に、Storybookのpropsを生成して渡せば、確実に全パターンが表示されます。

さらに、仕様書のほうで計算しておいた合計通り数で最終チェックもできるので完璧です。

Storybookで全パターンを網羅的に確認

props生成側のフロー

最後にContainerComponent側での利用シーンを紹介します。

ニコニコ生放送のフロントは長らく、状態管理にMobXのクラスパターンを使用しています。

ここでも漏れが発生しないよう、番組種別x番組状態の分岐を使用し、確実に網羅できる作りにしています。

  public get props(): ProgramWatchRejectedInformation.Props | undefined {
    switch (this.番組種別) {
      case "ユーザー生放送":
        switch (this.番組状態) {
          case "開始前":
            return this.ユーザー生放送_番組開始前_Props() || undefined;
          case "放送中":
            return this.ユーザー生放送_放送中_Props() || undefined;
          case "終了後":
            return this.ユーザー生放送_番組終了後_Props() || undefined;
          default:
            throw new TypeError(`Unknown 番組状態: ${this.番組状態}`);
        }
      case "CH系":
        // ...

引数の渡し方は、paramsの型で正しい組み合わせに限定されているため、この段階では実装を間違えることができません。

各メソッドで内で resolveProps() に対して 番組種別番組状態 を渡すと、不足する状態(今回は ロール)を要求する型エラーになり、ロール を足すと、 国別制限 を求められ、 要認証TS有効TS予約済み とエラーで要求されたものを足していくだけ。引数に渡す params のキー名とgetter名も合わせてあるため、渡し間違えるリスクも皆無です。

resolveProps(params: AllParams) の中で、resolveCondition(params: AllParams) が呼ばれ、 Condition オブジェクトから props に変換する処理を経由し、propsが返されます。

  private ユーザー生放送_番組開始前_Props(): ProgramWatchRejectedInformation.Props | null {
    return this.resolveProps({
      番組種別: "ユーザー生放送",
      番組状態: "開始前",
      ロール: this.ロール,
      国別制限: this.国別制限,
      TS有効: this.TS有効,
      要認証: this.要認証,
      TS予約済み: this.TS予約済み,
    });
  }

  private ユーザー生放送_放送中_Props(): ProgramWatchRejectedInformation.Props | null {
    // 省略
  }

  private ユーザー生放送_番組終了後_Props(): ProgramWatchRejectedInformation.Props | null {
    // 省略
  }
  // ...

あとは、一元管理された各状態の getter を埋めて終わりです。

  private get ロール(): "放送者" | "視聴者" {
    // 状態を参照する処理
  }

  private get プレミアム会員(): boolean {
    // 状態を参照する処理
  }
  // ...

まとめ

前回の記事で整理した112通りの複雑な表示を、いかに保守性の高い実装にできるか挑戦した内容を紹介しました。

何でもかんでも日本語で書けば良いわけではありませんが、日本語で書かれた複雑な仕様に対しては、範囲を限定して日本語を含めたコードで実装することも選択肢の一つです。それにより以下のようなメリットが得られます。

  • 仕様書と実装の直接的な対応が可能
  • ドメイン知識の理解が容易
  • レビュー時の認知負荷の軽減

このような実装であれば、大量の分岐を含む複雑な機能であっても完全に制御できている安心感があります。型が通った時点で不具合が混入する可能性は低く、仕様変更や修正が必要になった際も、手を付けるべき場所や影響範囲が明確。レビューも低コストかつ高精度で行えます。

これまでGateに手を入れる心理的ハードルは非常に高いものでしたが、この実装にしてからは全く不安なく触れるようになりました。

今回の記事が、複雑な仕様や実装に立ち向かう際の参考になれば幸いです。

https://zenn.dev/misuken/articles/32807daad601d0


株式会社ドワンゴでは、様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。

Discussion