ZodとPhantomType(NominalType)を組み合わせて、バリデーションを型レベルで安全にする
関数の引数に対して、バリデーション済みの値だけを受け取りたいということがよくあると思います。例えば、下の例のようにユーザーを作成する関数を考えます。
function createUser(param: {
username: string,
email: string,
phoneNumber: string
}): User => { ... }
- usernameは6文字以上32文字以下
- emailはe-mailの形式になっている
- phoneNumberは
XXX-XXXX-XXXX
のフォーマットになっている
などの制約があるため、どこかでバリデーションをしないといけません。しかし、createUser関数の中でそれらをバリデーションしてしまうと、責務の過多、関数の肥大化、バリデーションコードの分断が起きやすくなってしまいます。
または、バリデーションを外部化し、先にバリデーションしてからこの関数に渡すというルールを作るのもありかもしれませんが、その場合は新しく入ったエンジニアが間違えてバリデーションせずに値を渡してしまうといったリスクをはらんでしまいます。
そのためこの記事では、PhantomTypeとZodを組み合わせることで、バリデーション処理を分離しつつ、関数にはバリデーションされた値だけを受け取るように強制させられる方法をご紹介したいと思います。
Zodとは
Zodとは、値や、オブジェクトのフィールドの型や、値としての制約のバリデーションを行ってくれるライブラリです。API経由で取得したオブジェクトや、Jsonから読み込んだオブジェクトなど、TypeScriptの型通りである保証の無いオブジェクトをZodを通してバリデーションして使うことで、安全に利用可能にできます。
PhantomTypeとは
幽霊型、NominalTypeやBranded Typeなどとも呼ばれます。型に対して、コンパイル時にだけ有効となる型情報を付与し、型安全を強化することの出来る型です。実行時には削除され、パフォーマンスに影響も与えないため、幽霊型と呼ばれています。今回は、バリデーション済みであることをマークするためにこれを利用しています。
実装
PhantomTypeの定義
export type Phantomic<T, Tag extends string> = T & {
[key in `__PT__${Tag}`]: never;
};
export const convertPT = <Tag extends string>() => <T>(v: T): Phantomic<T,Tag> => v as any
Tagの名前に、通常のフィールドと被りが発生しないようにプレフィクスをくっつけたnever型のフィールドを定義することでPhantomTypeを実現しています。convertPT
は変換していますが、中は強制キャストしているだけです。無条件に変換できてしまうため、乱用は禁止です。使い方は続きのコードで
ユーザーの作成時パラメーター定義
export interface UnvalidatedUserCreationParam {
username: string
email: string
phoneNumber: string
}
export interface ValidatedUserCreationParam {
username: Phantomic<string, "Username">
email: Phantomic<string, "Email">
phoneNumber: Phantomic<string, "PhoneNumber">
}
UnvalidatedUserCreationParam
が未バリデーションのプリミティブなままのフィールドの定義です。
ValidatedUserCreationParam
が、バリデーション済みの値のみで構成されたフィールドの定義です。それぞれPhantomTypeで、意味を付与しています。
zodのバリデーション定義
import {z} from "zod"
export const ValidateUserCreation = z.object({
username: z.string().min(6).max(32).transform(convertPT<"Username">())
email: z.string().email().transform(convertPT<"Email">())
phoneNumber: z.string().regex(/^\d{3}-\d{4}-\d{4}$/).transform(convertPT<"PhoneNumber">())
})
transformメソッドを利用して、バリデーション完了した値にPhantomTypeを付与しています。
使用方法
function createUser(param: ValidatedUserCreationParam) => ...
// Compile Error
createUser({
username: "User1",
email: "hoge@example.com",
phoneNumber: "090-1234-5678"
})
// OK
createUser(ValidateUserCreation.parse({
username: "User1",
email: "hoge@example.com",
phoneNumber: "090-1234-5678"
}))
// 余談
// フィールドの代入ミスもコンパイルエラーで検出可能になります
const email: Phantomic<string, "Email"> = convertPT("hoge@example.com")
const a: ValidateUserCreation = {
// compile error
username: email
}
zodを通してバリデーションをかけると、すべてのフィールドが正しい型に変換されるため、createUserで受け取れます。
Validationのロジックが分離されたので、zodだけではサポートできない非同期のバリデーションを入れたい場合なども、createUserに手を入れずに拡張が可能になっています。
追記
他の実装方法として、zodのZodBrandedを使うことも可能です。使い方とかはZodでAlways-Valid Domain Modelを実現するが丁寧に説明してくれています。
Discussion