はじめに
ここ最近の業務では TypeScript を用いた webフォームの実装を担当させていただいています。
親愛なる上司が TypeScriptファーストなバリデーションライブラリの存在を教えてくださり、「プロダクトでどう使えるか考えてみるのも楽しいかも」とお題をくださったので、今回は手始めにZodの基本的な使い方について記事を書かせていただこうと思います。
Zod とは
Zod GitHub: https://github.com/colinhacks/zod
TypeScriptファーストなスキーマ宣言・バリデーションライブラリ。
ここでいうスキーマとはデータの型のことで、Zodではスキーマの宣言 → スキーマに沿っているか値を検証 というステップでバリデーションを行います。
公式にもある通り、「parse, don't validate」の思考に基づいた関数型プログラミングのアプローチを採用しており、与えられた値が無効だった場合はエラーを投げ、有効だった場合はパースした値を返す仕様になっています。(パースされた値といっても、ここでは有効と判定された場合にその値が返却される、という意味で捉えて良いかと思います。)
そして特筆すべき特徴は、スキーマから型定義を生成できること。
The goal is to eliminate duplicative type declarations. With Zod, you declare a validator once and Zod will automatically infer the static TypeScript type. (引用元: Zod 公式)
筆者訳: (Zod の)目的は重複した型定義を排除すること。一度 validatior を宣言すれば、 Zod が自動的に TypeScript の静的型付けを推測します。
つまり、宣言したスキーマから自動で型定義も生成することができる = 型の二重管理を防ぐことができるのです。
ちなみに TypeScript を使わないプロジェクトでも利用可能で、 Node.js 環境とIE11含むブラウザで動くとのこと。
なにはともあれ使ってみる
Zod の導入
お馴染みの
npm install zod
スキーマを宣言する
Zod にはプリミティブ型から関数型定義まで、さまざまな型定義に対応できるスキーマ宣言用のメソッドが豊富に用意されています。
今回は例としてシンプルに string 型のスキーマを宣言してみましょう。
import { z } from 'zod';
// string 型のスキーマを宣言
const name = z.string();
バリデーションしてみる
スキーマを宣言したら、以下のように.parse()
にバリデーションしたい値を渡すだけ。
前述の通り、チェックした結果有効な値だった場合はその値を返します。
name.parse('John'); // ok。 'John'を返す
name.parse(1000); // Error エラーを投げる
スキーマから型定義する
z.infer<typeof mySchema>
を使えば宣言したスキーマから TypeScript の型定義を生成できます。
type Name = z.infer<typeof name>
const myCatName: Name = 'Tama'; // OK
const myDogName: Name = 80; // タイプエラー発生
いろんなスキーマ宣言
前述の通り Zod には豊富なスキーマ宣言用メソッドが用意してあるため、さまざまなスキーマをシンプルに宣言できます。
string 型の 桁数 max/min などの検証用のメソッドもあり、チェーンすることで複数の条件を定義していけるのでより詳細なバリデーションもお手軽に実装可能です。
以下にいくつか例をあげてみます。
import { z } from 'zod';
// 5桁以上11桁未満の string 型
const passWord = z.string().min(5).max(10);
// emailアドレス
const email = z.string().email();
// オブジェクト型
const user = z.object({
name: z.string(),
age: z.number(),
})
// -> { name: string, age: number } の object
// 関数型
const myFunc = z.function().args(z.number(), z.string()).returns(z.boolean());
// -> (arg0: number, arg1: string) => boolean の関数
// もちろん型生成も可能
type MyFunc = z.infer<typeof myFunc>;
const isOverMaxLength: MyFunc = (maxLength, userName) => userName.length > maxLength;
isOverMaxLength('hoge', 'fuga'); // タイプエラー発生
エラーを扱う
Zod は チェックした値が有効な値でなかった場合、ZodError の instance であるエラーを投げます。
ZodError は Error のサブクラスとして定義されており、その issues プロパティがエラーの詳細を保持する ZodIssue の配列となっています。
class ZodError extends Error {
issues: ZodIssue[];
}
ZodIssue の詳細は公式をご参照ください。
以下に一例をあげてみましょう。
import { z } from 'zod';
const person = z.object({
name: z.string(),
age: z.number(),
});
try {
person.parse({
name: 1234, // number なのでエラーになるはず
age: "31" // string なのでエラーになるはず
});
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.issues);
}
}
上記のコードを実行した結果表示されるエラーは以下となります。
[
// 1つ目のエラー
{
code: "invalid_type", // Zodで定義されているエラーコード
expected: "string", // 期待される値
received: "number", // 実際に渡された値
path: ["name"], // エラーが発生した値へのパス この場合はプロパティ名
message: "Expected string, received number" // エラーメッセージ
},
// 2つ目のエラー
{
code: "invalid_type",
expected: "number",
received: "string",
path: ["age"],
message: "Expected number, received string"
}
];
上記のように、発生したエラーの情報が配列として返ってきます。
view 表示用にエラーを扱いやすくする
このままだと、フォームなどのバリデーションで用いる時に view に反映するのに少しデータ抽出が面倒かと思います。
そんな場合はflatten()
メソッドを用いれば扱いやすい型にチャチャっと整形できます。
先ほどのエラーをflatten()
してみます。
console.log(err.flatten());
// 以下が実行結果
{
"formErrors":[],
"fieldErrors": {
"name":["Expected string, received number"],
"age":["Expected number, received string"]
}
}
上記の fieldErrors
プロパティに、エラーが エラーになったプロパティ: エラーメッセージ
の形式になり、扱いやすくなりました。
ちなみに formErrors
プロパティには、スキーマの root でエラーが発生した場合に値が入ります。
今回の person
のケースだと、 オブジェクト型を期待しているのに null を渡した場合などが該当します。
try {
person.parse(null);
} catch (err) {
if (err instanceof z.ZodError) {
console.log(err.flatten());
}
}
// 以下が実行結果
{
"formErrors":["Expected object, received null"],
"fieldErrors":{}
}
エラーのカスタマイズ
Zod では既存で定義されているエラーコードに対するエラーメッセージもデフォルトで定義されていいますが、実際はエラーメッセージをカスタマイズしたいケースが大半ですよね。
ZodErrorMap 型の カスタムエラーマップ関数を生成することで、エラーメッセージをカスタマイズすることが可能です。
import { z } from "zod";
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
// string と number の invalid_type エラーメッセージのカスタマイズ
if (issue.code === z.ZodIssueCode.invalid_type) {
if (issue.expected === "string") {
return { message: "文字列を入力してください" };
}
if (issue.expected === "number") {
return { message: "数字を入力してください" };
}
}
// 上記以外はデフォルトで定義されたエラーを返す
return { message: ctx.defaultError };
};
エラーのカスタマイズは、このカスタムエラーマップをセットする方法によって優先順が変わります。
優先順位が低い順でご紹介します。
グローバルエラーマップ
z.setErrorMap()
にエラーマップを渡すことにより、グローバルなエラーマップのカスタマイズができます。
ここでセットしたエラーマップがデフォルトのエラーメッセージの設定となります。
z.setErrorMap(customErrorMap);
スキーマ固有に紐づけたエラーマップ
スキーマを宣言する際にエラーマップを設定することで、そのスキーマに紐づけたエラー設定ができます。
z.string({ errorMap: customErrorMap });
ちなみに、上記は以下のように設定するのと同義です。
z.string({
invalid_type_error: "文字列を入力してください",
});
stringだけでなく、全てのスキーマ宣言でエラーマップの設定が可能です。
コンテキストエラーマップ
最後に、parse()
する際にエラーマップをパラメータとして渡すことでそのparse処理に対するエラー設定ができ、この設定が何よりも優先されます。
z.string().parse("hogefuga", { errorMap: customErrorMap });
その他のエラーカスタマイズ
Zodではエラーメッセージだけでなく、エラーコードもカスタムできます。
気になる方はぜひ公式で詳細を見てみてください。
おわりに
TypeScript を用いた開発がメジャーな今、スキーマの作成(≒バリデーターの作成)と型定義が結びつくことによって重複コードが減り、変更にも強く&保守性も高められる点はとても魅力的だと感じました。
また、個人的には 「parse, don't validate」の思想も興味深く、Zodを通じてこの思想をうまく取り入れられるかを今後考えてみたいと思います。