valibotで実践するBranded types / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2024の2日目です。昨日は『valibotのご紹介』でした。
Branded typesとは
Branded typesを活用すると、同じプリミティブ型であっても型レベルで区別することが可能になります。たとえば、number
型を元にUSD
型とEUR
型を作ることで、単なる数値ではなく、ドルかユーロを型として区別できるようになります。これにより、単にnumber
型で実装するよりも、通貨を取り扱うロジックで思わぬ取り違いが発生することを防ぐことができます。
Branded typesという考え方はすでに何年も前からTypeScriptのコミュニティで取り入れられています。筆者も過去に『Branded Typesを導入してみる』という記事を書きましたが、これは多くの方に読まれ好評をいただきました。こうした手法によって、よりドメイン指向な型設計を実現することができるのです。次のコードは2年前に掲載したサンプルコードです。
type Brand<K, T> = K & { __brand: T }
type USD = Brand<number, "USD">
type EUR = Brand<number, "EUR">
const usd = 10 as USD;
const eur = 10 as EUR;
同様に、string
型を元にUserId
型やEntryId
型を定義すれば、単なる文字列ではなく「ユーザーID」や「エントリーID」として厳格に扱うことができます。これによって、ドメインモデルごとのユニークIDをより安全に操作できるようになり、型レベルでのミスを減らせます。一つの関数に2つのIDを引数として持つ場合などに、第3、第4の引数を追加してうっかり取り違えた経験、ありませんか?
Branded typesの浸透とvalibot
そして2024年となる現在、昨日紹介したvalibotによって、Branded typesの導入をさらに簡単にしています。2年前はどことなくハッキーな雰囲気というか、個人のアイデアを拝借するといったニュアンスがありましたが、ようやく大手のライブラリを介すことで堂々と使えるようになりました。
valibotはbrand()
という関数を提供しており、わざわざ独自の型定義トリックを用いずとも、手軽にブランド型を定義できるようになりました。2年前では少し工夫が必要だった分、一気に導入ハードルが下がったと言えるでしょう。
import { type InferOutput, brand, number, pipe } from "valibot";
const usd = pipe(number(), brand('USD'));
const eur = pipe(number(), brand('EUR'));
type USD = InferOutput<typeof usd>;
type EUR = InferOutput<typeof eur>;
コードの行数だけで言えば、2022年版と大差ないように見えますが、単体ではなくバリデーションライブラリそのものがBranded typesを前提として備える魅力をご紹介します。次のコードは、なんらかのitemId
に合致する複数の項目をページネーション形式で表示するWebアプリケーションのページだとします。
import {
type InferOutput,
brand,
number,
object,
parse,
pipe,
string,
} from "valibot";
const itemId = pipe(string(), brand("itemId"));
const page = pipe(number(), brand("page"));
const limit = pipe(number(), brand("limit"));
const searchParamsSchema = object({ itemId, page, limit });
type ParsedSearchParams = InferOutput<typeof searchParamsSchema>;
const parsed = parse(searchParamsSchema, searchParams);
URLのパスパラメータやクエリパラメータから、記事のID、ページ番号、1ページあたりの件数を得ることはよくあると思います。そして、だいたいこういった構造はstring
のみ、あるいはstring
, number
として扱われます。ところがvalibotのbrand()
を活用すると、もうparsed
変数はItemId
型の文字列、Page
型の数値、Limit
型の数値を持ち合わせた状態のオブジェクトとして得られるのです。valibotのparse()
を使うため、2022年とは違いas ItemId
などを書く必要はありません。
ここがbrand()
を今まで以上に導入したくなる大きな魅力です。この例ではあえて過剰に書いていますのでPage
型とLimit
型を分けるべきか、number
型のままでよいかどうかは各々が判断するところですが、string
一辺倒で取り違えが多発しがちなクエリパラメータについて、ランタイムバリデーションついでに型付けまで終わらせてくれるというのは非常に開発生産性を底上げしてくれます。その気になれば全てのプリミティブの値について、気軽にBranded typesを導入できるのです。
このような利便性を考えると、今からTypeScriptで新たなサービスやプロジェクトを始める場合、Branded typesを使わない手はありません。現に筆者は積極的に導入しており、チーム全員にもとても好評です。このように、ドメイン情報をより明確に型定義へと落とし込み、コードレビューやリファクタリングでの混乱を避ける上でとても役に立つはずです。
明日は『応用編 Valid branded types』
本日はvalibotで実践するBranded typesを紹介しました。明日はさらに踏み込んで、valibotの魅力をたくさん引き出すBranded typesの応用編をご紹介します。それではまた。
Discussion