🎄

実例 PageContext, AllowTransitionFrom / TypeScript一人カレンダー

2024/12/22に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の18日目です。昨日は『実例 ConvenienceFixture, orDefault()』を紹介しました。

App Routerへの大規模リアーキテクチャ

筆者が技術顧問を務める株式会社トレタモバイルメニューサービスでは、Next.jsPages RouterからApp Routerに置き換える大規模リアーキテクチャを実施しました。このリアーキテクチャの模様については過去のイベントにて紹介しています。

しかし、今後の開発をApp Routerベースで継続可能にはなりましたが、正直なところPages Router時代の遺構がまだ大量に残っています。たとえば、かつてgetServerSideProps()URLSearchParamsを検証していた部分などが方々に散らばっており、移行後も改修できていない課題が存在します。

複雑なパラメータを管理するPageContextという概念を整備

トレタ社のモバイルメニューサービスは、複数の大手POSシステム(レジ)との連携を念頭に置いているため、同じサービスでありながら連携先POSの仕様に応じて扱うパラメータが微妙に異なります。

これは、もともと社内単独で設計したデータ構造と、他社POSのデータ構造に完全な互換性がないことが原因で、特定のページを開く際に要求されるパラメータもPOS次第で変動します。その結果、単純なスキーマを定義するだけでは済まされず、ランタイムでどの店舗がどのPOSシステムを導入しているかを判別し、適切なパラメータ構造を強制しなければならないという煩雑さを抱えていました。

そしてそれらのパラメータの検証は、かつてgetServerSideProps()で実施していましたが、App Router化に伴いコンポーネントで実施するようになり、そこで検証された値がPropsを経由して各子コンポーネントに分配される実装に移り変わっていきました。

こうした事情から、Propsにてバケツリレーで渡される値の種類が複雑になってしまうため整理が求められ、プロジェクト内ではPageContext型と呼ばれる概念を導入しています。特定のページが必要とするパラメータと、エンドユーザーの操作主体(たとえば店員か来店客か)を組み合わせたDiscriminated unionとして表現し、各ページが必要とする情報を型レベルで明示的に示すようにしました。

もともとこの仕組みは、Pages Router時代はプロジェクト内全般で共有するLocator型と呼ばれる型でモデリングしていました。これは店舗(Location)の情報に基づくことが由来でしたが、改修の過程でLocationだけでなく多種多様な要素を識別する必要が出てきたため、App Routerへ移行したタイミングでより大域的なPageContextに移行し、その過程でValibot Branded typesも併用するようにしました。

ドメインモデルに基づいた考え方だと、単純に「ページに登場するパラメータを全部ひとつに混ぜる」というまとめかたではドメインを跨いでしまいます。そのため、ある種の割り切りとして「ページ」自体を表現する方向に舵を切りました。これは2年以上このプロジェクトを進めてきて見えてきた課題を、どう納得できるように解決すべきかという議論に基づいています。そのため、先行きのまだわからないWebアプリケーションが最初からこの概念を真似するべきかどうかは、一考すべきでしょう。

どこで使いたいものか、どこでなら再利用してよいのか

Branded typesを備えたPageContextを導入したことで「よく似た見た目のコンポーネント」でも、それがどの画面のために実装されたものなのか、Propsの型情報を見るだけで判別できるようになりました。

たとえばPropsにてItemPageContextを求めるコンポーネントは「自身が商品ページに配置される」ことを示唆しており、PayPageContextを求めるコンポーネントは「自身が支払いページに配置される」ことを示唆します。この示唆は一種の「密結合」ですが、巨大なモノリスコンポーネントを分割せずに運用するか、いくつかの密な子コンポーネントに分割すべきかは、可読性・保守性・テスト性の観点で議論できます。今回は分割を好みました。

コンポーネントは再利用性を意識すべしとはよく言われますが、一度立ち止まって考え直してみましょう。本当にグローバルに「すべてのページでどこでも」使えますでしょうか。抽象化を進めれば、いくつかは使えるかもしれませんが、意外とちょっと大きな部品ほど、使えることを想定した画面数は数えるほどにまで減ります。

概念を整理する前の旧概念Locatorを扱う実装では、Locatorさえ持っていればコンポーネントををどこでも配置できました。しかし「このコンポーネントってよく似てるけどこの画面用だっけ?」みたいな気を遣うコードレビューが少しずつ増え、Lintなどでの縛りにも限界がありコードレビュアーへの負担が問題となっていました。sharedディレクトリに突っ込まれたせいで「どこでも使えるらしいけど、本当のところは、どことどこなの?」といった背景を探りながらコードレビューを実施することになってしまっていたのです。

PageContextの導入によってこの不安が解消されました。会計前の明細なのか、会計後の明細なのか、割引券が使えるのはどのタイミングなのか、そういった金銭の絡むシビアな表示内容について、コンポーネント自身が型レベルで用途を明言できるようになり、コードリーディング時の負担は大幅に減少しました。

複雑な遷移を型レベルで制御

モバイルメニューサービスの画面遷移は、「メニュー一覧」「商品ページ」「注文リスト(買い物カゴ)」「明細画面」「カード番号入力画面」「決済完了画面」など多岐にわたる画面があり、どのページからどのページに進めるという厳格なフローが定められています。

さらに、利用客の会計の前後によってエンドユーザーの状態によっても遷移フローが動的に変化するため、その状態管理や把握も難易度が高いものとなっています。

TypeScriptによる実装でこれらを表現するには、型レベルで「どの画面からどの画面へは遷移できるか」を示しておきたいものです。この制約が曖昧だと、不用意に「通ってはいけない画面遷移」を実装できてしまいバグに繋がってしまいます。

Notionに書き溜められた仕様書や社内ドキュメントを常に参照していても、両者に差異が出たり更新漏れが発生すると不整合が生まれがちです。そのため、可能な限りTypeScriptコードのみで画面遷移制限を固めたいという要望が高まりました。このようにコードで全てのドメインを表現したいという欲求は、ドメイン駆動設計などの知識に基づきます。

AllowTransitionFrom型の実例

以下に示すAllowTransitionFrom型は、そうした要件を踏まえて設計されたユーティリティ型です。ValibotのBrandSymbolを組み込み、そのままではStructural typingになってしまうところをNominal的な情報を付与することで、許可されないオブジェクトがすり抜けるリスクを抑えています。

import type { BrandSymbol } from "valibot";

export type AllowTransitionFrom<FROM extends symbol, REQUIRED> = REQUIRED &
  {
    [K in FROM]: { [BrandSymbol]: { [key in K]: K } };
  }[FROM];

たとえば次の例では、Constraint型が複数のシナリオを合成しています。

type Constraint =
  | AllowTransitionFrom<
      // プレビュー機能の入口はひとつのみ
      Home,
      Readonly<{
        subject: "preview";
        locationId: LocationId; // 店員が使う。店舗だけ特定できればいい。
      }>
    >
  | AllowTransitionFrom<
      // POSごとにQRコードが異なり複数の画面から遷移されうる
      Home | OxPosEntrance | ExternalPosEntrance,
      Readonly<{
        subject: "session";
        locationId: LocationId; // 飲食店利用客が使う。店舗だけでなく
        sessionId: SessionId;   // どのテーブルなのか特定する必要がある。
      }>
    >;

この制約では、「subject"preview"locationIdを持っている状態」ではHome画面から進入してよいと定義し、あるいは「subject"session"locationIdsessionIdを持っている状態」ではHome画面、OxPosEntrance画面、ExternalPosEntrance画面から進入してよいと定義しています。

subjectプロパティはサービスの「操作主体」を意味し、"preview"機能を利用するのは飲食店スタッフ、一方で"session"は飲食店利用客のテーブルひとつひとつが操作していることを指します。こうすることで、"preview""session"を両方満たしたいためにsessionId?: SessionId;のようにオプショナルを使う必要がなくなります。オプショナルはいかなる場面でも厳密性が薄れるものとして警戒しています。

またこの定義からは、当初Homeと呼ばれる画面だけでまかなっていたが、POS連携機能の拡充に伴いQRコードの仕様がいずれも異なるという点を吸収するために、複数の入口画面が追加で実装されたことに伴う実装の吸収も伺えます。「なんちゃらEntrance」が乱立してしまっているのは開発が並行で進んだことによる苦肉の策なのですが、この仕組みによって「どのPOSシステムや状況であれば、どのパラメータが必須なのか」を型レベルでチェック可能となり、なんとか着地できています。

こうして、POSシステムや操作主体の差分によってsessionIdの必須・不要が変わるなど複雑な制約を、型レベルで管理できるようになりました。

なぜページ名の分岐パラメータを増やさないのか

一方で、pageName: stringpageName: "Home" | "OxPosEntrance"のようなプロパティを追加し、ランタイムでif (pageName === "Home") { ... }と分岐する選択肢も考えられます。そのようにする開発者は多いでしょうし、素直な実装に見えます。

しかしこれは、本プロジェクトでは意図的に避ける判断をしました。ひとつのWebアプリケーションでありながら、バックエンドのPOS連携が多岐にわたり、フロントエンドの操作主体も様々である本案件では、分岐パラメータの種類を不用意に追加してしまうと、開発者があらゆる箇所でif/elseを乱立できてしまうリスクが高まり、コードの可読性や保守性が急激に下がってしまう懸念がありました。これを防ごうとすると、念入りなオンボーディングが必要となり、実装難度を上げてしまいます。開発中に常に気にするべき事情を増やすということは、属人性を高め、開発組織の柔軟な拡張を阻害する要因になってしまいます。

分岐を気軽に書けてしまう問題への回避策として、GoFのStrategyパターンや、クライアントコンポーネントの再利用を徹底する方針(常に抽象化を検討する方針)などチーム内でガイドラインとして設定し「分岐を気軽に書かせない構造」をアーキテクチャ全体のポリシーとして導入しています。

そしてpageNameプロパティを追加しないという発想も、AllowTransitionFrom型での「型レベルで気付ける仕組み」だけがあれば十分という発想であり、ランタイム上で余計な分岐やパラメータを追加する余地を意図的に制限しています。

「自由を奪う」ことのメリット

連携先POSが多岐にわたる環境では、分岐やパラメータの組み合わせが常に膨大で、無制約・無秩序であればあるほど、開発者のストレスが簡単に増大します。そこで「どうやって実装の自由を奪い制約を課していくか」が重要な検討事項でした。

アプリケーション開発を始めたての開発者からすると、「開発の自由度を奪うなんて不便」という印象を受けるかもしれません。自由を奪うのは一見デメリットに思えますが、実は「開発者が業務中に気にするべきことを減らし、ストレスを低減させる」ことにつながります。

本件において「制約を表現すること」と「分岐に使えてしまうノイズを増やすこと」はまったく別の問題でした。ただでさえパラメータの組み合わせが膨大な以上、さらに種類が増えるのはノイズに他なりませんでした。

ページ上の制約は「型レベルの制約」で気付けるようにしたかっただけで、ランタイムで余分なパラメータを追加してしまうと、開発者がそこから「分岐実装」を生んでしまう余地を持ってしまいます。プロジェクトのレールから外れさせないためには、チームのみんなをレールに乗せ続ける工夫、つまりパラメータを提供しないという選択が必要でした。そこで、余計なパラメータを増やさずあくまでもBranded typesという型の情報だけで制限をかける方針を取りました。

これを別の視点から見れば、「ある種のフレームワーク・オン・フレームワーク」という言い方もできるかもしれません。この取り組みは書籍『Clean Architecture 達人に学ぶソフトウェアの構造と設計(アスキードワンゴ、Robert C.Martin著)』における第21章「叫ぶアーキテクチャ」の体現でもあります。この章では「設計図を一目見ればそれが家庭用の戸建てなのか図書館なのか判別できる」と説いており、同様にTypeScriptによる制約を最大限活用することで、モバイルメニューサービスのための構造であると「見ればわかる」形に仕上げる努力をしました。

フレームワーク・オン・フレームワークの是非は、よく保守性の観点や、設計者の退職後の負債化などで、デメリットの側面で語られることも多いですが、筆者は常に「チーム全員がどうすれば開発中の迷いをなくせるか」を念頭に置いて設計しており、仮に自身が不在になっても維持できるもの、あるいは簡単に除去できるものの提供を心がけています。

まとめ

大規模リアーキテクチャを経て、新たな画面遷移要件が生まれたApp Router時代のNext.js。そこにValibotでのNominal typing的な要素を取り込んで、AllowTransitionFrom型のようなユーティリティを導入することで、複雑な画面遷移に対する制約の表現を型レベルでしっかり表現できるという内容を紹介しました。

このような制約のおかげで、コンパイル時のエラーだけでなく、プログラミング中にコードエディタが誤った画面遷移を自動検出し、すぐに赤下線を表示し誤りに気付けるようになっています。自由を縛ることは、ときに開発者の負担を軽減し、バグと複雑さを削減する有効な戦略です。

明日は『実例 UnknownifyDiscriminatedUnion』

本日は『実例 PageContext, AllowTransitionFrom』を紹介しました。明日は『実例 UnknownifyDiscriminatedUnion』を紹介します。それではまた。

Discussion