Zodで真のTypeScript firstを手にする

2 min read読了の目安(約2500字

fullstack TSなアプリケーションも増えてきた昨今、TSでvalidatorを実装する際に何を採用するかは一大トピックです。今回は、その中でも新しめなライブラリでありBlitzも採用しているZodについて見ていきます。

Zodとは

https://github.com/colinhacks/zod
Zodの特徴として、Schema firstなvalidationライブラリであるというのがあります。
validateするschema(単一のschemaからobject, arrayまで)を定義し、それをベースにparseするというものです。

公式にあるexampleを見てみましょう。

import { z } from "zod";

// creating a schema for strings
const mySchema = z.string();
mySchema.parse("tuna"); // => "tuna"
mySchema.parse(12); // => throws ZodError

schemaを定義し、schema.parseしてparseできなければエラーを吐きます。
objectでも同じです。

import { z } from "zod";

const User = z.object({
  username: z.string(),
});

User.parse({ username: string });

// extract the inferred type
type User = z.infer<typeof User>;
// { username: string }

z.infer<T>を使うことにより、zodで定義したschemaから型情報を得ることもできます。

使い方はシンプルですね。

既存の型との整合性をどう取るか?

Zodを使うとまず悩むのが、ZodのSchemaと既存の型のダブルメンテ問題です。これはzodがややTypeScript FirstというよりもZod Schema Firstな設計となっているためです。

そのため、何も考えずに実装していくとダブルメンテをしなくてはなりません。

// ORM等から得られる既存型情報
type User {
  name: string
}

// zodでのスキーマ
const UserZodSchema = z.objecct({
  name: z.string()
})

これでは、フィールドが増えれば増えるほどタイポや入れ忘れによるランタイムエラーを引き起こしやすくなってしまいます。
例えば、Userにfieldが追加された時にzodSchemaを更新しなくてもTSはエラーになりません。

type User {
  name: string
  zipCode: string // <- new
}

// zipCodeを追加し忘れ
const UserZodSchema = z.objecct({
  name: z.string()
})

解決策1(not Recommended) ts-to-zod

https://github.com/fabien0102/ts-to-zod
ts-to-zodは、名前の通り既存のTSの型からzod schemaを生成するライブラリです。
yarn ts-to-zod user.ts userZodSchema.ts
// input
export interface User {
   /**
   * The name of the user.
   *
   * @minLength 2
   * @maxLength 50
   */
  name: string;
}

// output
export userSchema = {
   /**
   * The name of the user.
   *
   * @minLength 2
   * @maxLength 50
   */  
 name: z.string().min(2).max(50),
}

個人的には、validatorをauto generatedなロジックに頼りたくないのと、実際のアプリケーションでは型情報以上のvalidationをする機会も多いので、あまり実用的とは思えません。

解決策2(Recommended) schemeForType Utility functionを定義する

下記のような、既存の型とZodSchemaの型の整合性をチェックする関数を定義してあげます。

const schemaForType = <T>() => <S extends z.ZodType<T, any, any>>(arg: S) => {
  return arg;
};

これを使えば、既存の型情報と整合性が保たれない場合TSの型エラーを出すことができます。

type User {
  name: string
  zipCode: string // <- new
}

// zipCodeがなのでTSエラー
const UserZodSchema = schemaForType<User>()(
  z.objecct({
    name: z.string()
  })
)

参考

https://github.com/colinhacks/zod/issues/372#issuecomment-826380330