🎄

応用編 Valid branded types / TypeScript一人カレンダー

2024/12/16に公開

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

さらに一歩踏み込むValid branded types

昨日の記事では、valibotが備えるbrand()関数によって、Branded typesを用いた型定義とランタイムバリデーションを一元化できる魅力を紹介しました。本日はそこから一歩踏み込み、Branded typesを「常にスキーマを満たしていると保証された型」として「Valid branded types」という活用方法を取り上げます。

2年前の筆者の記事ではFilledString型を紹介していました。当時はasString()assertString(), isString()といった関数を自作して、空文字列が入り得ない「常に中身が入っている文字列型」を表現していたのです。次のコードは、2年前の記事の引用です。

function isString(v: unknown): v is string {
  return typeof v === 'string';
}

function assertString(v: unknown, target = ''): asserts v is string {
  if (!isString(v)) {
    throw new Error(`${target} should be string`.trim());
  }
}

function asString(v: unknown): string {
  assertString(v);
  return v;
}

ところが、valibotを使えば、こういった補助関数類は一切自作する必要がありません。pipe()brand()、そしてvalibot標準のバリデータ、例えばここではstring()minLength()を組み合わせることで、「空文字列にはなり得ない文字列型」としてFilledString型を簡潔に定義できます。

FilledString型をvalibotで実現する

もうasFilledString()のような関数を作る必要はありません。たとえば、次のようなコードでFilledString型を実現できます。

import { type InferOutput, brand, minLength, pipe, string } from "valibot";

const filledString = pipe(
  string(),
  minLength(1),
  brand("filledString"),
);

type FilledString = InferOutput<typeof filledString>;

export function asFilledString(v: unknown): FilledString {
  return parse(filledString, v);
}

たったこれだけでよいのです。pipe()ベースのインタフェースを提供するvalibotでは、string()がどのような文字列であるかをpipe()の後続の引数にして定義します。たとえば、最小文字数最大文字数であったり、Eメール形式であるか、ISO日時形式であるかなど、複数のActionが定義済みのため、それを渡すだけでスキーマを定義できます。Actionが足りない場合は、もちろん自作することもできます

そして、pipeの最後段にbrand()を挿入しBranded typesとします。こうするだけで、一瞬でFilledString型が定義できるのです。2年前にあれこれ工夫した上で記事として紹介していましたが、それらは今ではすべてvalibotを使うだけでよいのです。

1文字以上であることが必須な応用型の定義

filledStringスキーマが成り立つと、もっと応用したくなります。たとえばUserエンティティに自動付与されるidが空文字になるはずがありません。filledStringスキーマを満たすUserId型を定義して、他のfilledString型と区別してみましょう。

const userId = pipe(
  filledString,
  brand("userId"),
);

type UserId = InferOutput<typeof userId>;

export function asUserId(v: unknown): UserId {
  return parse(userId, v);
}

このように、brand()は重ねがけが有効です。この場合、asUserId()の戻り値はFilledStringを求める箇所とUserIdを求める箇所の両方に対して適合します。ItemId型を定義したとすれば、その型との互換性はありません。また、pipe()filledStringを使わずに1からuserIdスキーマを定義すれば、FilledString型とUserId型の相互の互換性は生まれません。brand()の重ねがけを好むか、すべて独立して定義するかは匙加減でもあります。valibotのpipe()はとても柔軟ですので、好みに応じて複雑な型を定義することも可能です。

時間型もより厳密に

FilledStringと同じ発想で、Millisecond型やUnixTime型のような、特定フォーマットや範囲を保証した数値型を簡単に作成できます。たとえば、UnixTime(秒単位)とUnixTimeMs(ミリ秒単位)を区別したい場合でも、brand()を使ってそれぞれの時間単位専用の型として定義可能です。

Date.now()はミリ秒を返すがデータベースの値は秒だから1000倍しないといけない……」こんな経験はよくあるんじゃないでしょうか。now()関数を自作する場合は次のようになります。

import { type InferOutput, brand, integer, number, parse, pipe } from "valibot";

const unixTimeMs = pipe(number(), integer(), brand("unixTimeMs"));

type UnixTimeMs = InferOutput<typeof unixTimeMs>;

function now(): UnixTimeMs {
  return parse(unixTimeMs, Date.now());
}

こうすることでグローバルなDate.now()を実装内で直接使うよりもJestVitestでモックしやすいという副次的な効果も期待できます。このようにvalibotを導入するだけで、TypeScriptプログラミングの日常的な様々なシーンがちょっと気持ちよくなる感覚を得られます。

2年前に紹介していた手法では、独自の型定義トリックやカスタム関数を何個も組み合わせる必要があり、どうしても「手作り感」のある実装になっていました。オレオレライブラリが成長しすぎると、今度はその社内ライブラリの作者が退職したあとのメンテナンスにもリスクが伴います。それがvalibotの登場によって、一つ上の型設計を積極的に支援するライブラリによるメンテナンスコストからの解放というメリットが得られます。

2年で変わったTypeScriptエコシステム

ここで感じるのは、TypeScript本体だけでなく、その周辺エコシステムも2年で大きく変化するという点です。筆者が紹介していたような2年前に有効だったテクニックが陳腐化し始めるものもあり、2年後にはより洗練された方法で実現できるようになっています。 こういった進化を踏まえ、筆者は2年ぶりに一人アドベントカレンダーを再開しようと思い至りました。過去の記事で紹介したテクニックも、valibotなどのライブラリの力を借りて、よりシンプルかつ明快に書き直せることを紹介したかったのです。

明日からも、2024年ならではのTypeScriptの使い方やツールを紹介していきます。2年前の記事から踏襲できる部分、そして刷新したい部分を見定めながら、実務で役立つ小ネタをお届けできればと思っています。

明日は『文字列や配列の最大長が決まっていないときの対策』

本日は「応用編 Valid branded types」を紹介しました。明日もvalibot活用の小技として『文字列や配列の最大長が決まっていないときの対策』をご紹介します。それではまた明日。

Discussion