💎

Zodのソースコードを少し読んでみた話

2023/03/30に公開

はじめに

バリデーションライブラリの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()がどのメソッドのエイリアスなのかを明らかにし、ZodStringZodTypeemail()parse()の実装を見れば、何をしているか分かりそうです。

z.string()について

z.string()のような書き方ができるのは、ZodStringのインスタンスを生成するメソッドcreate()string()として以下のようにエクスポートしているからでした。

// types.ts から抜粋
const stringType = ZodString.create;

export {
//省略(他のエクスポート)
stringType as string,
//省略(他のエクスポート)
}

クラス図

types.tsZodStringZodTypeの実装がありました。ZodTypeparse()を見ると、safeParse()を実行しており、safeParse()_parseSync()を実行したり、handleResult()を実行していました。まずは、ZodStringZodTypeにどのようなフィールドやメソッドがあるのか、クラス図を使って整理しました(今回の調査に関係ある部分だけ抜粋しています)。

ZodTypeZodStringについて以下のようなことが分かりました。

  • ZodTypeabstract class ZodType<Output = any, Def extends ZodTypeDef = ZodTypeDef, Input = Output>となっており、抽象クラスです。また、型引数のInputのデフォルト値がOutputとなっており、明示的に指定がない場合はInputの型はOutputの型になります。
  • ZodStringclass ZodString extends ZodType<string, ZodStringDef>となっており、ZodTypeの継承クラスになっています。OutputInputstring型、DefZodStringDefを型引数として渡しています。また、抽象メソッド_parse()を実装しています。

ZodString_parse()の実装を見れば、バリデーションが具体的に何をしているか分かりそうです。

メソッドの実装の詳細

z.string()ZodStringemail()ZodTypeparse()について、詳細を見ていきます。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()ZodStringcreate()のエイリアスです。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を省略した場合は空のオブジェクトになるため何も入らない
    });
  };

ZodStringemail()について

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],
    });
  }

ZodTypeparse()について

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()のさらに詳細

ZodTypesafeParse()について

safeParse()ParseContext型のインスタンスctxを生成し、parse()の引数のdatactx_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()ctxresultから、バリデーションに成功した場合は{ success: true; data: Output }の型のインスタンスを、失敗した場合は{ success: false; error: ZodError<Input> }の型のインスタンスを生成します。ctxにはバリデーションに失敗した結果を保持しておくissuesというフィールドがあり、このissuesの情報を使ってZodErrorissuesのフィールドの値が生成されます。

ZodType_parseSync()について

_parseSync()メソッドは単純で、_parse()メソッドを実行してSyncParseReturnTyperesultをリターンします(非同期処理だった場合は例外をスローします)。_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のフィールドの_defchecksにある項目でバリデーションを実行し、バリデーションのステータス(aborteddirtyvalidのいずれか)とバリデーションを行ったインスタンスをリターンします。
バリデーションに失敗した場合は上述のsafeParse()内で生成したParseContext型のインスタンスctxissuesというフィールドにバリデーション失敗の情報を追加します。

  _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の時はエラーをスローします。
また、バリデーションが失敗した結果の詳細な内容が知りたい時は、ZodErrorissuesの中身をみることで何のバリデーションで失敗したか分かります。

`ZodError`の`issues`の中身の例
[
  {
    validation: 'email',
    code: 'invalid_string',
    message: 'Invalid email',
    path: []
  }
]

感想

  • まずは比較的難易度が高くなさそうなstring型の簡単なバリデーションがどのような処理になっているか理解できました。
  • 次はobject型のバリデーションがどうなっているかを調査したり、inferを使ってスキーマから型がどのように生成されているかなどをみていきたいです。
    • object()でパースをした場合は、ZodErrorissuesの各要素の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は以下のように定義されており、SafeParseSuccessSafeParseErrorのユニオン型です。

export type SafeParseReturnType<Input, Output> = SafeParseSuccess<Output> | SafeParseError<Input>;

SafeParseSuccessSafeParseErrorはそれぞれ、下記のように定義されています。ZodErrorErrorを継承した型でフィールドに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

ZodErrorErrorの拡張で、フィールドにZodIssue型のリストissuesがあります。

export class ZodError<T = any> extends Error {
  issues: ZodIssue[] = [];

  get errors() {
    return this.issues;
  }
  // 省略
}

ZodIssue

ZodIssueOptionalMessagefatalmessageの直積型です。

export type ZodIssue = ZodIssueOptionalMessage & {
  fatal?: boolean;
  message: string;
};

実際に動かして、ZodErrorissuesを出力してみると、下記のようになっていました。

[
  {
    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