🎄

実例 neverUsed(), $() / TypeScript一人カレンダー

2024/12/17に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の5日目です。昨日は『文字列や配列の最大長が決まっていないときの対策』を紹介しました。

Valibot利用上のさらなる工夫

昨日に続き、Valibotを使う上で役立つちょっとした工夫を紹介します。今回取り上げるのはneverUsed()$()という2つのユーティリティです。

neverUsed(): 使用しないプロパティを明示する

大規模なアプリケーションを開発していると、社内外の多種多様なシステムと接続する場面に遭遇します。それらのシステムが提供するAPI仕様書には、膨大なプロパティが定義されていることも珍しくありません。そしてその中には、自分たちのアプリケーションでは一切使わないプロパティが多数含まれていることもよくあります。

こうしたとき、仕様書と自分たちがValibotを使って書き上げたスキーマとを見比べたときに「このプロパティは存在するはずだが、スキーマには定義されていない。これは記述漏れ?それとも意図的な省略?」といった迷いが生じやすくなります。そこで、プロパティ自体をスキーマから削ってしまうのではなく「このプロパティは存在するが、私たちのアプリケーションでは使っていない」という事実を明示する方針を取るようにしています。昨日紹介した「最大値未定」を明示するパターンと同様に、意図をコードで表すことで、読み手に「決して忘れたわけではない」というメッセージを伝えます。

そのために定義された関数がneverUsed()です。

import { unknown } from "valibot";

export const neverUsed = unknown;

ただ別名を付け直しただけですが、その通りでこれはエイリアスを意図しています。 このエイリアスを定義する前は、単にunknown()を使い毎回コメントで「未使用プロパティ」と書き添えることで対応していました。しかしそれでは大量のunknown()と、毎回同じようなコメントが大量に並んでしまい、スキーマ全体を眺めたときに圧迫感がありました。「コメントをコピペするくらいなら、いっそコメント自体を関数にしよう」という発想で生まれたのがneverUsed()です。

これであれば、neverUsed()が登場する箇所は「使わないプロパティ」という意味がコード化されており、コメントなしで意図がわかります。機能的にはunknown()と同じですが、Valibot側でunknown()の破壊的な挙動変更が将来起こらないとも言い切れないため、現時点でのunknown()に期待している挙動を我々のリポジトリ内での単体テストとして念のため回していたりします。

$(): strictObject()のエイリアスで可読性向上

Valibotには、looseObject(), object(), strictObject()という3種類のオブジェクトスキーマの宣言が存在します。それぞれ、スキーマに対してどれだけ厳格にプロパティをチェックし、未知のプロパティを許容するかが異なります。

  • looseObject(): スキーマで定義したプロパティは検証するが、parse時にスキーマに定義していないプロパティをそのまま残す
  • object(): parse時にスキーマで定義していないプロパティは除去する
  • strictObject(): スキーマと完全に一致しない場合はValiErrorをthrowする

このなかでどれをどのように使うべきかという選択は、開発チームやプロジェクトの文化、対象システムやその開発者・チームとの信頼関係、社内外かどうか、破壊的変更がどの程度予期されるかといった要因によって大きく変わってきます。迷うようであれば一旦object()から始めてみるのも手です。

とはいえあまりにもルーズだと、使われていないプロパティがいつまでも残ったり、実際には不要なデータが紛れ込み続けたりします。昨今はTypeScriptだけでバックエンドとフロントエンドを両方実装する例も多くなりましたが、ブラウザ上に見せてはいけないクレデンシャルな情報が混入してしまうリスクもありますので、筆者はおすすめしません。

逆に厳格すぎると柔軟性に欠けますが、「何が使われていて、何が使われていないか」を機械的に明らかにでき、不要なコードやプロパティを簡潔に排除できますし、混ざっているはずのないデータが混ざっておりインシデントに繋がるというリスクも回避できます。自由なプロパティを許さないという姿勢は対脆弱性という観点もあり、Prototype Pollution脆弱性への対策にも繋がります。昨今では回避されることが当たり前になってきたため知名度が下がっている気配はありますが、自由を許すということは想定していないリスクに曝されることにも繋がるため、筆者にはなるべく防御側に倒したいという信条があります。ただしstrictObject()を使いすぎる結果、他のシステムの変更に過敏に反応してすぐにValiErrorがthrowされることになるため、厳格すぎてクラッシュが多いなど不利益が発生している場合は、柔軟に使い分けるべきです。

そして、結果的に筆者のチームではstrictObject()を積極的に使う場面が多くなりました。ですが、そのたびにstrictObject()と書くようでは、可読性が下がっていると感じました。次のコードは本稿のための架空のコードですが、このように入れ子構造にするとstrictObject()が悪目立ちしやすいです。

// リクエストオブジェクトはbody以外も複数含むため object() のまま
const request = object({
  body: strictObject({
    items: array(
      strictObject({
        name: string(),
        quantity: number(),
        category: strictObject({
          id: string(),
          label: string(),
        }),
      }),
    ),
    options: strictObject({
      sortBy: union([
        literal("name"),
        literal("quantity"),
        literal("category"),
      ]),
      filters: strictObject({
        searchKeyword: string(),
        minQuantity: number(),
        maxQuantity: number(),
      }),
    }),
  }),
  metadata: strictObject({
    requestId: string(),
    client: strictObject({
      name: string(),
      version: string(),
    }),
  }),
});

そこで、チームの合意のもと$()というエイリアスを導入し、strictObject()を短く表現しています。

import { strictObject } from "valibot";

export const $ = strictObject;

さらにスキーマを示す変数では末尾に$を使うようにしました。例えば、ユーザースキーマをuser$、ユーザーIDスキーマをuserId$、リクエストボディのスキーマをbody$といった具合に末尾に$をつけるルールを採用することで、その変数がひと目でスキーマだと分かるようにしています。

これはfetch処理などで仮引数として登場するitemId値とスキーマとしてのitemIdが同時に出現するような実装がしばしば現れ、毎回import { itemId as itemIdSchema }のようにエイリアス処理をする煩雑さを回避する目的があります。

そうして書き換えた例が次のコードです。itemName$categoryId$などはBranded typesを導入した想定です。

const request$ = object({
  body: $({
    items: array(
      $({
        name: itemName$,
        quantity: number(),
        category: $({
          id: categoryId$,
          label: string(),
        }),
      }),
    ),
    options: $({
      sortBy: sortBy$,
      filters: $({
        searchKeyword: string(),
        minQuantity: number(),
        maxQuantity: number(),
      }),
    }),
  }),
  metadata: $({
    requestId: requestId$,
    client: $({
      name: string(),
      version: string(),
    }),
  }),
});

$という文字は、JavaScriptでは伝統的な意味を持っています。かつてjQueryやAngularJS、RxJSなど、多くのJSフレームワークやライブラリが特別な意味合いを持たせていました。その習慣に倣い、「$がついている変数はスキーマだ」という独自ルールをチーム内で共有し、可読性と意図の伝達性を高めているのです。もちろんこれは$が同一案件内で複数の意味・用途を持っていない前提ではあります。

エイリアス導入の是非

こうしたエイリアスを増やしすぎると、かえって「これとこれの違いは何だろう?」と新たな混乱を生む可能性もあります。そのため筆者は原則的にエイリアスを多用することをあまり好みません。なぜなら、エイリアスの提供意図が明確でなかったり、ドキュメント化やチーム周知が不十分だったりすると、むやみに混乱を増やしているだけになるからです。

しかし、今回ご紹介したneverUsed()$()は、コメントを多用しなくても意図が伝わるように改善されることや、チーム合意の上で導入したことで、コード全体の可読性の向上に寄与しました。つまり、「エイリアス導入のコスト」よりも「ドキュメントがなくとも意図が明確になるメリット」の方が大きいと判断できたわけです。

本カレンダーでたびたび述べているように、アプリケーションコードはプログラムであることに加え、意図を伝えるコミュニケーション手段でもあります。どうすればよりよいコミュニケーションとなるか、Valibotの利用ひとつをとっても様々な工夫が考えられます。

このように、エイリアス導入はチームやプロジェクトの事情次第です。自分たちの文化や設計思想、運用体制を踏まえて、エイリアスを導入する価値があるかを判断すれば良いでしょう。

明日は『DeepReadonly<T>』

本日は「neverUsed(), $()」を紹介しました。明日はTypeScriptのユーティリティ型『DeepReadonly<T>』を紹介します。それではまた。

Discussion