DeepReadonly<T> / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の6日目です。昨日は『実例 neverUsed(), $()』を紹介しました。
DeepReadonlyを求める理由
2年前のカレンダーでReadonly
型について紹介したことがあります。筆者はReadonly
型を好んでおり、特殊なアルゴリズム処理や複雑なリクエスト生成の処理を除けば、ほぼ全てのオブジェクトは不変であってほしいと感じており、積極的にReadonly
を指定しています。基本的に、データ構造に再代入やミューテーションが起きない方が、コードベースは格段に読みやすくなり、バグの発生を抑えることができるからです。これは変数宣言についてlet
よりもconst
を常用する理由と等しいです。
しかし、実際にValibotを使って型定義とランタイムバリデーションを一元化していると、その推論結果(InferOutput
型によって得られる型)はReadonlyではありません。これは、valibotが返す型構造が可変な型として表現されているためです。それはJavaScriptプログラミングである以上なんら不思議なことではありません。
ただ筆者としては、これらの出力型を再帰的にReadonly
扱いにして、parse
された結果が不変であることを型レベルで保証したいという欲求が出てきました。
ts-essentialsのDeepReadonlyを導入する
ここで、もちろん再帰的なReadonly
型を自分で実装することも可能ですが、TypeScriptの型レベル・プログラミングは複雑になりがちです。そこで便利なのが、ts-essentials
というユーティリティ集です。
これを導入すれば、DeepReadonly
といった汎用的な型ユーティリティを簡単に使うことができます。DeepReadonly
以外にも豊富なユーティリティ型が提供されていますので、一覧を眺めるだけでもアイデアが刺激されます。
DeepReadonly
を導入すると、InferOutput
が返す入れ子構造を持った型に対して、再帰的に全プロパティをReadonly
化できます。こうすることで、Valibotで定義したスキーマから取得したデータは必ず不変であることを型システムが保証し、誤った代入や予期しないミューテーションによる不具合を未然に防ぐことが可能になります。
DeepReadonly
は次のように使用します。
import type { DeepReadonly } from "ts-essentials";
import { type InferOutput, strictObject } from "valibot";
const body$ = strictObject({
// 長く複雑なBodyのスキーマ
});
type Body = DeepReadonly<InferOutput<typeof body$>>;
もし常用するようであれば、DeepReadonly<InferOutput<T>>
として一つの自作ユーティリティを作ることもできますが、それは好みといってよいでしょう。複数のライブラリを混ぜた一つのユーティリティをどこまで作り込むかは、バランス感覚が求められる分野です。
そして、そもそもの話としてts-essentials
をプロジェクトの依存関係に追加するかどうかは、判断が求められます。「依存が増える」ことをただそれだけで懸念する開発者もいます。しかし、一方で自作の複雑な型ユーティリティをメンテナンスし続けるコストや、長期的な可読性・保守性の観点を考えると、ここでの外部ライブラリの導入は十分検討に値します。
ts-essentials
はTypeScriptレベルのユーティリティのため、最終的なJavaScriptバンドルサイズへの影響もなく、ドキュメントやコミュニティでの知名度もあるエコシステムとして枯れ始めている段階のため、依存することによる「依存しなかった場合の長期的なコスト」を低減してくれると判断して導入を決定しました。
ライブラリ導入時に考えたいこと
このカレンダーの1日目からValibotを積極的に紹介していたり、今回のts-essentialsの紹介であったりなど、外部ライブラリの話題は「追加したらどう嬉しいか」に目が向きがちです。
ですが、それだけでなく「導入によって失われる可能性があるもの」にも注意を払うべきでしょう。ライブラリが複雑すぎてかえって理解が難しくなったり、メンテナンスが停止して保守性が損なわれたりといったリスクも存在します。ライブラリの寿命やメンテナンス状況、コミュニティの活発さ、スポンサーやメンテナーの資金源、将来の破壊的変更への対処法など、OSSには多面的な「背景」が存在しており、それらをあらゆる方向から考慮する必要があります。これはすべて「このライブラリは導入すべきかどうか」の判断材料となり得るのです。
そしてライブラリは、導入したら終わりではなく、常に「どう手放すか」を考える姿勢が求められます。 たとえば筆者はとてもValibotを好んでいますが、もし将来Valibotが利用できなくなったとしても、ランタイムバリデーションやBranded typesといった概念そのものはJavaScriptやWebアプリケーション開発から消え去ってしまうことはありません。いざという時には別のライブラリの採用や独自実装で代替できるという道筋を初めから想定した上でvalibotの採用を決定しています。単純に「便利だから」ではなく入れる前から「万が一無くなった時どうするか」を含めて判断します。
ts-essentialsについても同様です。DeepReadonly
のような型ユーティリティを利用する利点と、万が一メンテナンスが途絶えた際にどう対応するかという出口戦略を、頭の片隅に留めた上で導入します。
もし導入したライブラリが将来メンテナンスされなくなったり、開発終了に至ったとしても、OSSである限り、その実装に関する情報やコードを振り返ることは可能です。極端な例では、必要最低限の機能を自分たちのプロジェクト内に取り込み、独自にメンテナンスすることも考えられます。もちろん、その際はライセンスの遵守やコード品質の維持、保守コストの増加を踏まえる必要がありますが、「OSSはコードが読める」という点がライブラリ選定時の一つの安心材料となります。また、コミュニティ有志によるフォークや名称変更を経た再開といったケースも珍しくありません。そのため、こうした動向には常にアンテナを張り、不安定な要素があるライブラリについてはその推移を注視しておくとよいでしょう。
アプリケーションは常に変化し、ライブラリやツールは必ず寿命へ向かって進み続けます。長期的な運用を見据え、メリットが大きいと判断した場合には思い切って導入し、導入するからにはそのライブラリを最大限活用するとともに、いつでも柔軟に切り替えられる姿勢を保つことが重要です。
まとめ
DeepReadonly
を適用することで、Valibotから得た出力型を不変な型として扱うことができ、コードベース全体の安全性や可読性を向上できるようになりました。もちろん、ライブラリ追加に伴うリスクもありますが、捨て方を常に考え、適切なユーティリティを組み合わせて取り入れていくことで、TypeScriptによる型安全な開発体験がさらに豊かになるでしょう。
明日は『StrictOmit<T, K>』
本日は「DeepReadonly」を紹介しました。明日は別のユーティリティ型『StrictOmit<T, K>
』をご紹介します。それではまた。
Discussion