Zodのソースコードを少し読んでみた話
はじめに
バリデーションライブラリのZodを仕事で担当しているプロジェクトに導入したいと考えています。文字列でメールアドレスの形式を満たしているかどうかのバリデーションをしたい場合は下記のようなコードで直観的にバリデーションをすることができます。
私はどちらかと言うとバックエンド側が得意で、フロントエンド側があまり得意ではありません。フロントエンドが得意ではない私が急に「Zodを導入しましょう!」と言っても、「何を言っているんだコイツ??」となりそうです。
チームメンバーへZodを紹介するために、本稿では下記のコードでどのようにバリデーションが実装されているか、Zodのソースコードを読んでまとめました。
import { z } from "zod";
const email = z.string().email();
email.parse("test@example.com"); // pass
email.parse("invalid_email"); // => ZodError: Invalid email
// email.parse({ email: "test@example.com" }); // => ZodError: Expected string, received object
ソースコードリーディング
VSCodeの型情報の表示機能で読む部分のあたりをつけました。
- サンプルコードの
z.string().email();のstring()の部分にカーソルを合わせると、(alias) string(...と表示されました。string()はz.ZodStringに実装されているメソッドのエイリアスのようです。また、email()の部分にカーソルを合わせると、`(method) ZodString.email(...'と表示されました。 -
email.parse(のparse()にカーソルを合わせると(method) ZodType<string, ZodStringDef, string>.parse(...と表示されました。
以上のことから、string()がどのメソッドのエイリアスなのかを明らかにし、ZodStringやZodTypeのemail()やparse()の実装を見れば、何をしているか分かりそうです。
z.string()について
z.string()のような書き方ができるのは、ZodStringのインスタンスを生成するメソッドcreate()をstring()として以下のようにエクスポートしているからでした。
// types.ts から抜粋
const stringType = ZodString.create;
export {
//省略(他のエクスポート)
stringType as string,
//省略(他のエクスポート)
}
クラス図
types.tsにZodStringとZodTypeの実装がありました。ZodTypeのparse()を見ると、safeParse()を実行しており、safeParse()は_parseSync()を実行したり、handleResult()を実行していました。まずは、ZodStringとZodTypeにどのようなフィールドやメソッドがあるのか、クラス図を使って整理しました(今回の調査に関係ある部分だけ抜粋しています)。
ZodTypeとZodStringについて以下のようなことが分かりました。
-
ZodTypeはabstract class ZodType<Output = any, Def extends ZodTypeDef = ZodTypeDef, Input = Output>となっており、抽象クラスです。また、型引数のInputのデフォルト値がOutputとなっており、明示的に指定がない場合はInputの型はOutputの型になります。 -
ZodStringはclass ZodString extends ZodType<string, ZodStringDef>となっており、ZodTypeの継承クラスになっています。OutputとInputはstring型、DefはZodStringDefを型引数として渡しています。また、抽象メソッド_parse()を実装しています。
ZodStringの_parse()の実装を見れば、バリデーションが具体的に何をしているか分かりそうです。
メソッドの実装の詳細
z.string()やZodStringのemail()やZodTypeのparse()について、詳細を見ていきます。Zodの独自の型については備忘録も兼ねてAppendixにまとめています。
ソースコードを読むにあたっての仮説
実際に動かしてみた結果から、以下の動きをしているのではないか?という仮定でソースコードを読み進めていきました。
-
z.string()でZodStringのインスタンスが生成され、email()やmax()などをメソッドチェーンしていくと、インスタンスに何らかの形でバリデーションのオプションが追加されていく。
-
parse()を実行すると、メソッド内でバリデーションの結果と引数の値を保持するオブジェクトが生成され、バリデーションの結果に基づいて引数の値がそのままリターンされたり、例外がスローされる。
メソッドの概要
| メソッド名 | クラス | 概要 |
|---|---|---|
| z.string() | ZodString |
ZodStringのインスタンスを生成する。 |
| email() | ZodString | emailのフォーマットを満たすかどうかのバリデーション項目が付与されたZodStringのインスタンスを生成する。 |
| parse() | ZodType | バリデーションを実行して、バリデーションに成功した場合は引数の値をリターンし、失敗した場合は例外をスローする。 |
| _parse() | ZodString | ZodTypeの抽象メソッドの実装。ZodStringのフィールドにどのバリデーションを行うかの項目があり、項目に従ってバリデーションを行う。ZodTypeのparse()から間接的に実行される。 |
z.string()について
string()はZodStringのcreate()のエイリアスです。create()はZodStringのインスタンスをリターンします。
// coerceにtrueを渡すと、parse()で渡した引数に対してString(引数)を実行して、stringに変換してからバリデーションが行われる
static create = (params?: RawCreateParams & { coerce?: true }): ZodString => {
return new ZodString({
checks: [],
typeName: ZodFirstPartyTypeKind.ZodString, // <= ZodStringという文字列が入る
coerce: params?.coerce ?? false, // デフォルトはfalseが入る
...processCreateParams(params), // paramsを省略した場合は空のオブジェクトになるため何も入らない
});
};
ZodStringのemail()について
email()は_addCheck()を実行しています。_addCheck()はもとのインスタンスの情報に新しいバリデーションの項目を追加したZodStringのインスタンスを新たに生成してリターンします。
email(message?: errorUtil.ErrMessage) {
return this._addCheck({ kind: "email", ...errorUtil.errToObj(message) });
}
_addCheck(check: ZodStringCheck) {
// 元のZodStringのインスタンスの情報と引数のcheckをcheksに追加して新しいZodStringのインスタンスをリターンする
// ZodStringはchecksのリストに従ってバリデーションを実施する
return new ZodString({
...this._def,
checks: [...this._def.checks, check],
});
}
ZodTypeのparse()について
parse()メソッド自体は単純で、safeParse()の返り値のオブジェクトのsuccessの値がtrueなら返り値のオブジェクトのdataのプロパティをリターンし、falseならerrorのプロパティをスローします。
parse(data: unknown, params?: Partial<ParseParams>): Output {
const result = this.safeParse(data, params);
if (result.success) return result.data;
throw result.error;
}
このsafeParse()の中身を更に追っていくと、_parse()が呼ばれていることが分かります。この_parse()は抽象メソッドで、ZodTypeを継承したクラスで実装されています。
ZodTypeのparse()のさらに詳細
ZodTypeのsafeParse()について
safeParse()はParseContext型のインスタンスctxを生成し、parse()の引数のdataとctxを_parseSync()メソッドの引数とします。_parseSync()はバリデーションの結果とデータをフィールドに持つSyncParseReturnType型のインスタンスを返します。
ParseContext型のインスタンスctxを内部で実行するメソッドの引数で渡すことで、メソッド内の内部状態を保持・記録します。ctxのフィールドにはバリデーションのエラーを記録するissuesというものも含まれます。
safeParse(
data: unknown,
params?: Partial<ParseParams>
): SafeParseReturnType<Input, Output> {
const ctx: ParseContext = // (省略)
const result = this._parseSync({ data, path: ctx.path, parent: ctx });
return handleResult(ctx, result);
}
handleResult()はctxとresultから、バリデーションに成功した場合は{ success: true; data: Output }の型のインスタンスを、失敗した場合は{ success: false; error: ZodError<Input> }の型のインスタンスを生成します。ctxにはバリデーションに失敗した結果を保持しておくissuesというフィールドがあり、このissuesの情報を使ってZodErrorのissuesのフィールドの値が生成されます。
ZodTypeの_parseSync()について
_parseSync()メソッドは単純で、_parse()メソッドを実行してSyncParseReturnTypeのresultをリターンします(非同期処理だった場合は例外をスローします)。_parse()メソッドはクラス図のところで書いたように、ZodTypeを継承したクラスで実装されます。
_parseSync(input: ParseInput): SyncParseReturnType<Output> {
const result = this._parse(input);
if (isAsync(result)) {
throw new Error("Synchronous parse encountered promise.");
}
return result;
}
ZodStringの_parse()について
_parse()はZodTypeのフィールドの_defのchecksにある項目でバリデーションを実行し、バリデーションのステータス(abortedかdirtyかvalidのいずれか)とバリデーションを行ったインスタンスをリターンします。
バリデーションに失敗した場合は上述のsafeParse()内で生成したParseContext型のインスタンスctxのissuesというフィールドにバリデーション失敗の情報を追加します。
_parse(input: ParseInput): ParseReturnType<string> {
if (this._def.coerce) {
// ZodStringのインスタンスを生成するときにcoerceをtrueにしておけば、
// 他のオブジェクトをparseした時に文字列に変換してからバリデーションの処理を開始する。
// z.string({ coerce: true }).email()
input.data = String(input.data);
}
const parsedType = this._getType(input);
// 省略(バリデーションしたいinputがstring型なのかチェックする)
const status = new ParseStatus();
let ctx: undefined | ParseContext = undefined;
for (const check of this._def.checks) {
// 省略(他のタイプのバリデーション)
} else if (check.kind === "email") {
if (!emailRegex.test(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
validation: "email",
code: ZodIssueCode.invalid_string, // invalid_stringという文字列
message: check.message, // email()の時に文字列を引数で渡していれば、その文字列
});
status.dirty();
}
}
// 省略(他のタイプのバリデーション)
return { status: status.value, value: input.data };
}
まとめ
サンプルコードで何が起きているか?
長くなってしまったのでサンプルコードを再掲しつつ、サンプルコードで何が起きているかまとめます。
import { z } from "zod";
const email = z.string().email();
email.parse("test@example.com"); // pass
email.parse("invalid_email"); // => ZodError: Invalid email address
// email.parse({ email: "test@example.com" }); // => ZodError: Expected string, received object
z.string().email()でZodStringのインスタンスを生成してリターンします。ZodStringにはparse()メソッドが実装されており、parse()の処理の中で引数の値とバリデーション結果を保持したインスタンスが生成され、バリデーション結果がOKの時は引数の値を返し、NGの時はエラーをスローします。
また、バリデーションが失敗した結果の詳細な内容が知りたい時は、ZodErrorのissuesの中身をみることで何のバリデーションで失敗したか分かります。
`ZodError`の`issues`の中身の例
[
{
validation: 'email',
code: 'invalid_string',
message: 'Invalid email',
path: []
}
]
感想
- まずは比較的難易度が高くなさそうなstring型の簡単なバリデーションがどのような処理になっているか理解できました。
- 次はobject型のバリデーションがどうなっているかを調査したり、
inferを使ってスキーマから型がどのように生成されているかなどをみていきたいです。-
object()でパースをした場合は、ZodErrorのissuesの各要素のpathにどのフィールドでバリデーションが失敗したかの情報があるので、インスタンスのどのフィールドで何のバリデーションに失敗したかが分かります。
`ZodError`の`issues`の中身の例
[ { code: 'too_small', minimum: 2, type: 'string', inclusive: true, exact: false, message: 'String must contain at least 2 character(s)', path: [ 'hoge' ] }, { code: 'too_big', maximum: 2, type: 'string', inclusive: true, exact: false, message: 'String must contain at most 2 character(s)', path: [ 'fuga' ] }, { validation: 'email', code: 'invalid_string', message: 'Invalid email', path: [ 'foo' ] } ] -
- OSSとかで独自の型が暗記できない量があるときに、型の関係を図示したいのですが、皆さんはどのように整理しているのでしょうか?
Appendix
Zodで定義されている型の調査メモ
types.tsで定義されている型の調査メモ
備忘録も兼ねて書いています。ここで挙げた型はtypes.ts)に実装されています。
SyncParseReturnType
SyncParseReturnTypeは以下のように定義されており、OK<T>とDIRTY<T>とINVALIDのユニオン型です。
export type SyncParseReturnType<T = any> = OK<T> | DIRTY<T> | INVALID;
OK<T>とDIRTY<T>とINVALIDは下記のように定義されており、バリデーションのステータスとバリデーションをした値を保持する型となっていいました。
export type OK<T> = { status: "valid"; value: T };
export type DIRTY<T> = { status: "dirty"; value: T };
export type INVALID = { status: "aborted" };
ParseReturnType
ParseReturnType は以下のように定義されており、上述のSyncParseReturnType<T>とAsyncParseReturnType<T>のユニオン型です。AsyncParseReturnType<T>については、今回の調査の対象外なので、詳細は見ません。
export type ParseReturnType<T> =SyncParseReturnType<T> | AsyncParseReturnType<T>;
SafeParseReturnType
SafeParseReturnTypeは以下のように定義されており、SafeParseSuccessとSafeParseErrorのユニオン型です。
export type SafeParseReturnType<Input, Output> = SafeParseSuccess<Output> | SafeParseError<Input>;
SafeParseSuccessとSafeParseErrorはそれぞれ、下記のように定義されています。ZodErrorはErrorを継承した型でフィールドにZodIssue型のリストの型のissuesを持ちます。
export type SafeParseSuccess<Output> = { success: true; data: Output };
export type SafeParseError<Input> = { success: false; error: ZodError<Input> };
parseUtil.tsで定義されている型の調査メモ
parseUtil.tsに実装されています。
ParseInput
export type ParseInput = {
data: any;
path: (string | number)[];
parent: ParseContext;
};
ParseContext
メソッド内で状態を保持する時のインターフェースです。commonの中のissuesにバリデーションで失敗した結果が入ります。
export interface ParseContext {
readonly common: {
readonly issues: ZodIssue[];
readonly contextualErrorMap?: ZodErrorMap;
readonly async: boolean;
};
readonly path: ParsePath;
readonly schemaErrorMap?: ZodErrorMap;
readonly parent: ParseContext | null;
readonly data: any;
readonly parsedType: ZodParsedType;
}
ZodError.tsで定義されている型の調査メモ
ZodError.tsに実装されています。
ZodError
ZodErrorはErrorの拡張で、フィールドにZodIssue型のリストissuesがあります。
export class ZodError<T = any> extends Error {
issues: ZodIssue[] = [];
get errors() {
return this.issues;
}
// 省略
}
ZodIssue
ZodIssueOptionalMessageとfatalとmessageの直積型です。
export type ZodIssue = ZodIssueOptionalMessage & {
fatal?: boolean;
message: string;
};
実際に動かして、ZodErrorのissuesを出力してみると、下記のようになっていました。
[
{
code: 'too_small',
minimum: 2,
type: 'string',
inclusive: true,
exact: false,
message: 'String must contain at least 2 character(s)',
path: [ 'hoge' ]
},
{
code: 'too_big',
maximum: 2,
type: 'string',
inclusive: true,
exact: false,
message: 'String must contain at most 2 character(s)',
path: [ 'fuga' ]
},
{
validation: 'email',
code: 'invalid_string',
message: 'Invalid email',
path: [ 'foo' ]
}
]
Discussion