🎄

valibotで実践するBranded types / TypeScript一人カレンダー

2024/12/16に公開

こんにちは、クレスウェア株式会社の奥野賢太郎 (@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