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
では実現できない number
のunion
リテラル型が定義できます
// 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() })
]);