お知らせ フロントエンド バックエンド インフラ 品質保証 セキュリティ 製品 興味・関心 その他

スキーマ定義をyupからzodに変更した話

TypeScriptで開発中のプロジェクトで使用するスキーマ定義のライブラリをyupからzodに変更した際の知見です。

スキーマ定義のライブラリを使う理由

TypeScriptは完全ではないにしろ型安全な言語ですが、その保証の範囲は内部で定義したデータのみです。外部(バックエンドから取得したデーターやユーザーの入力データーなど)は対象になりません。本稿のスキーマ定義ライブラリは、外部データーからの防衛を目的としています。

途中で変更した理由

当初使用していたyup(0.31.1)で型推論の問題が多くあったため

TypeScriptの型付けがバージョンが1未満なこともあってか、yupには型推論に問題が多くありました

型エラーにならない

※現時点で最新のyup@1.2.0では正しくエラーになるようです

import * as yup from "yup";

const yupNumberSchema = yup.number();
const yupExample1: yup.InferType<typeof yupNumberSchema> = "abc";
// yup@0.31.1: エラーにならない
// yup@1.2.0: Type 'string' is not assignable to type 'number'

// requiredをつければエラーになりますが、エラーが分かりづらいです
const yupRequiredNumberSchema = yup.number().required();
const yupExample2: yup.InferType<typeof yupRequiredNumberSchema> = "abc";
// yup@0.31.1: Type 'string' is not assignable to type 'Id<Partial<Pick<number, KeyOfUndefined<number>>> & RequiredProps<number>>'.ts
// yup@1.2.0: Type 'string' is not assignable to type 'number'.

Union Typeの定義が困難

.test.whenによって定義しなければならず、推論される型も anyになります

// 以下の様に書けますが、yup.InferType<typeof stringOrNumberSchema> は any[] になる
const stringOrNumberSchema = yup
  .array()
  .required()
  .test("test", (values) =>
    values.every(
      (value) => typeof value === "string" || typeof value === "number"
    )
  );
// Genericsによる型定義の例。以下では型エラーになる
// Genericsなので、validateは効かない。(yup@1.2.0では、Genericsが廃止された模様)
const stringOrNumberSchema = yup.array<string | number>().required();
const ret: yup.InferType<typeof stringOrNumberSchema> = ["aaa", 1];
// Type 'string' is not assignable to type 'InnerInferType<string | number>'

// validateSyncを使用すれば一応エラーは解消できる
const ret: ReturnType<typeof stringOrNumberSchema.validateSync> = ["aaa", 1]; // OK

破壊的変更を多く取り込む必要があった

先述の問題点は現在は解消しているようですが、yupのメジャーバージョンアップには破壊的な変更があり躊躇していました。変更の取り込みを行うのであれば別のライブラリに入れ替えてしまうのもありかと考えました。

下は、aまたはbが必須 の定義例です。最新版の yupでは動作しません。

const schema = yup
  .object()
  .shape(
    {
      a: yup.string().when("b", {
        is: (b: string) => !b || b.length === 0,
        then: yup.string().required(),
        otherwise: yup.string()
      }),
      b: yup.string().when("a", {
        is: (a: string) => !a || a.length === 0,
        then: yup.string().required(),
        otherwise: yup.string()
      })
    },
    [["b", "a"]]
  )
  .required();

zodのほうが型推論が強い

yupでは実現できない numberunion リテラル型が定義できます

// yupではnumberの union リテラル型が定義できない
const yupNumber = yup
  .number()
  .oneOf([5, 10] as const)
  .required();
type YupNumber = yup.InferType<typeof yupNumber>; // number

// zodだと定義できる
const zodNumber = zod.union([zod.literal(5), zod.literal(10)]);
type ZodNumber = zod.infer<typeof zodNumber>; // 5 | 10

入力データと出力データどちらの型定義も推論できます

const schema = zod
  .object({ a: zod.string() })
  .transform((data) =>
    typeof +data.a === "number" ? +data.a : undefined
  );

type Input = zod.input<typeof schema>;
// { a: string }
type Output = zod.output<typeof schema>; // zod.infer<typeof schema> と同じ
// number | undefined

その他yupとzodの比較

オブジェクトのスキーマに定義していないプロパティは削除される

zodの場合、スキーマにないプロパティは削除されます。

// yupの場合未定義プロパティはそのまま残る
const yupSchema = yup.object({
  a: yup.string().required(),
  b: yup.string().required()
});
console.log(yupSchema.validateSync({ a: "aaa", b: "bbb", c: "ccc" }));
// { a: "aaa", b: "bbb", c: "ccc" }

// zodの場合消える
const zodSchema = zod.object({
  a: zod.string(),
  b: zod.string()
});
console.log(zodSchema.parse({ a: "aaa", b: "bbb", c: "ccc" })); 
// { a: "aaa", b: "bbb" }

データを取得 -> 更新を行う処理が存在する場合、意図せずレコードを欠損させてしまうことがありますので注意が必要です。

const mockFetch = async () => ({ name: "テスト 太郎", score: 100 });
const mockPut = async (data: unknown) => console.log(data);

(async () => {
  const schema = zod.object({ score: zod.number() });
  const data = await mockFetch().then((data) => schema.parse(data));
  const newData = { ...data, score: 50 };
  await mockPut(newData); 
  // { score: 50 } (schemaにnameがないため、nameが欠損する)
})();

TypeGuardの書き方

const value: unknown = "abc"; // unknownとして定義

// yupでは isValidSyncを使用する
if (yup.string().required().isValidSync(value)) {
  value; // string
}

// zodでは safeParse結果がtrueになった時のdataを使用する
const ret = zod.string().safeParse(value);
if (ret.success) {
  ret.data; // string
}

schemaはclass

yupのschemaは型ですが、zodのschemaはclassで定義されています。

このメリットとして、jestで expect.any() によるテストができます。

const func = (schema: zod.ZodSchema) => console.log(schema)
func(zod.object({ a: zod.string() }));
expect(func).lastCalledWith(expect.any(zod.ZodObject))

再帰表現

yup, zodとも lazy を使用することで再帰的な記載ができます。この場合先に型を定義する必要があります。

// 再帰的な型定義
type Label = { name: string; children: Label[] };

const data: unknown = {
  name: "test",
  children: [{ name: "test", children: [] }]
};

// yupの場合
const schema: yup.ObjectSchema<Label> = yup
  .object()
  .shape({
    name: yup.string().required(),
    children: yup
      .array()
      .of(yup.lazy(() => schema))
      .required()
  })
  .required();

console.log(schema.isValidSync(data));
// -> true

// zodの場合
const schema: zod.ZodType<Label> = zod.object({
  name: zod.string(),
  children: zod.lazy(() => zod.array(schema)),
});

console.log(schema.safeParse(data).success);
// -> true

aまたはbが必須

先ほど yup で記載した、どちらかが必須の例は zodでは以下の様に書けます

const schema = zod.union([
  zod.object({ a: zod.string(), b: zod.string().optional() }),
  zod.object({ a: zod.string().optional(), b: zod.string() })
]);
中村

中村

記事一覧

中村です。
株式会社マーケライズでマーケティングオートメーション(MA)のMRCを開発しています。
現在はMRCのフロントエンド刷新のため、Reactを使用した開発に切磋琢磨しています。
フルリモートです。
枯れたと思っていた植木を半年くらい放っておいたら、梅雨の雨で復活しました。