💎

Zodで真のTypeScript firstを手にする

2021/04/29に公開4

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

Discussion

Teruhisa - T6ADEVTeruhisa - T6ADEV

1年以上前の記事に対して発言失礼します。
個人的な見解には以下のように思っています、何か思うことあれば返信いただければです。

yoshihiro nakamurayoshihiro nakamura

こちら気づかずすみませんでした。いえいえ、言及ありがとうございます。

自分の書き方が分かりづらかったかもしれませんが、もしかすると課題意識が自分と違っているかもです。
自分の課題意識は、zod schemaよりも先に作られた既存型情報があり、かつその既存型情報のほうがsource of truthである場合(e.g. バックエンドならPrismaが生成した型情報、フロントエンドならGraphQL codegenなどで生成された型情報)、zod schemaを作るor保守する際に2重管理的にならざるを得ないのでは、というものでした。

z.inferですが、これはzod schemaから型情報を得るAPIですので、source of truthはzod schemaとなるわけです。これは、REST APIなど既存型情報がない環境ではむしろ便利だと自分も思っていて、zod schemaを中心とした設計が成立すると思います。一方、PrismaやhasuraのようなDBソリューションと型生成が一体となったようなツールではDBのスキーマを中心とした設計にするべきなので、zodのschemaに関して「既存の型情報に沿っているよね」ということを担保したいということになります。

なお、現在はTS 4.9の satisfiesを使うときれいに実装できそうです。

type User = {
    id: number
    name: string
    age: number
}

const UserSchema = z.object({
    id: z.number(),
    name: z.string(),
    age: z.number()
}) satisfies z.ZodType<User>
Teruhisa - T6ADEVTeruhisa - T6ADEV

返信ありがとうございます!

既存型情報のほうがsource of truthである場合

たしかにその通りですね。置き換え自体がそもそも不可能という観点が抜けていました・・・
codegenされた型を使わざるを得ない場合はまさにそうですね。All TSならば、という前提の思い込みがありました。

satisfiesの活用いいですね!勉強になります、ありがとうございます!

https://tsplay.dev/NB4xbW

HTMLGOHTMLGO

良記事ありがとうございます!

inferからtypeを作るとtypeのコードが無いので、
schemaの設定が間違っていてもわかりづらく、、
この記事のようにtypeからschemaを生成すると
typeもちゃんとコードがあるので検証しやすかったです。

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

おっしゃる通りで
この方法、最初便利だなーと思っていたのですが、typeに修正が入るたびにgenerateしないと
schemaとズレるので怖いなと思いました。

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

こちら便利ですね!typeとschemaの整合性エラーも出してくれて、
この方法が一番いいと思いました。

satisfiesで代用出来るのも知らなかったです。