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