zod + prismaの構成の注意点と、zodのfalsyな値のバリデーションの検証
今関わっているプロジェクトでzodもprismaも初めて触っている。
そんな中、この2つのかみ合わせが良くなさげ(何も知らない人にとっては、の前置き付き)な部分が見つかったのと、それによりいろいろな気づきを得たのでまとめておく。
# 何が起きたか
業務系システムにおけるある画面で、textareaの内容を更新しようとした際に、以下のような挙動が観測された。
1. zodで例えばz.string().optional()としたフィールドを用意する
2. textareaに"abc"と入力し、上記のzodスキーマを通じたバリデーションをかける
3. "abc"は問題ないのでprisma経由でDBに保存される
4. 次に、先ほどのtextareaの"abc"の文字などをすべて消し、再度zodスキーマに渡す
5. 文字入力のされていないtextareaではvalue === undefinedとなるが、zodスキーマにoptional()を指定しているのでバリデーションが通る
6. その後prismaにundefinedを渡すと、素の設定のprismaではundefinedはDo Nothingを意味するので、元々"abc"が入力されていたフィールドは更新されず、"abc"のままでupdateが完了する(エラーもなし)
これの何が問題かというと、
z.string().optional()は直感的にも設定していしまいがち
なのにprismaは、undefinedを渡されてもDo Nothingな動き(*)らしく、あるフィールドで「文字列→undefined」に更新しようとするとupdateは成功するがフィールドの値が変更されない、ということが起こる
しかもこの不具合は気づきにくい (一度文字列を入れて保存→削除して保存、の動きで初めて気づく)
というあたり。
* Do Nothingな動きについてはこちら参照: https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined#current-behavior
# どうしたか
上記のリンクのトップに記載されていることだが、prismaの5.20.0以降にはstrictUndefinedChecksというpreview featureオプションがあり、これにより「クエリのoptionに渡されるvalueとしてundefinedがあると、エラーになる」という動きに変更できる。
設定自体は簡単で、shema.prismaに以下のように記載を行い、prisma migrateを実行するだけ。
code:schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = "strictUndefinedChecks" // ←これを追加
}
なーんだそんだけか、と思われたかと思う。
だが、得られた気付きとして大きかったのはこちらではなく下記の部分。
# zodのバリデーションちゃんと理解する (特にnullishな値周り)
上記の設定を行った後、undefinedが許容されなくなったのでzodのスキーマも変更する必要が出てきた。
が、「バリデーションに通った後の戻り値としてundefinedではない値を渡すには、どうすればよいか」が頭の中でまとまっていなかった。
これまで、zodではいろいろなバリデーションのチェーンメソッドが用意されていて、なんとなーくで選んでしまいがちだった。
とはいえ、じゃあどういう条件でどういう判定・変換がされるのかが明記されているのかというとそうでもなく、例えば公式のドキュメントで.optional()について調べてみても、やはりその辺り書かれていない。
https://zod.dev/api?id=optionals
--- --- --- --- --- --- ---
ということで、いくつかのzodスキーマを用意し、それぞれにfalsyな値や境界値周辺の値を渡す、ということを行うvitestのテストを書いてみた。
コードは長いので本文の最後尾に記載するが、先に結果を。
* フォーマットはスキーマ > バリデーション結果: 渡した値 -> 戻り値としている
code: 結果
✓ tests/zod/zod.test.ts (45 tests) 141ms
✓ z.string().optional() > pass: undefined -> undefined
✓ z.string().optional() > fail: null -> undefined
✓ z.string().optional() > fail: true -> undefined
✓ z.string().optional() > fail: false -> undefined
✓ z.string().optional() > fail: 0 -> undefined
✓ z.string().optional() > pass: "" -> ""
✓ z.string().optional() > fail: [] -> undefined
✓ z.string().optional() > fail: {} -> undefined
✓ z.string().optional() > fail: NaN -> undefined
✓ z.string().nullish() > pass: undefined -> undefined
✓ z.string().nullish() > pass: null -> null
✓ z.string().nullish() > fail: true -> undefined
✓ z.string().nullish() > fail: false -> undefined
✓ z.string().nullish() > fail: 0 -> undefined
✓ z.string().nullish() > pass: "" -> ""
✓ z.string().nullish() > fail: [] -> undefined
✓ z.string().nullish() > fail: {} -> undefined
✓ z.string().nullish() > fail: NaN -> undefined
✓ z.string().nullable() > fail: undefined -> undefined
✓ z.string().nullable() > pass: null -> null
✓ z.string().nullable() > fail: true -> undefined
✓ z.string().nullable() > fail: false -> undefined
✓ z.string().nullable() > fail: 0 -> undefined
✓ z.string().nullable() > pass: "" -> ""
✓ z.string().nullable() > fail: [] -> undefined
✓ z.string().nullable() > fail: {} -> undefined
✓ z.string().nullable() > fail: NaN -> undefined
✓ z.string().default("") > pass: undefined -> ""
✓ z.string().default("") > fail: null -> undefined
✓ z.string().default("") > fail: true -> undefined
✓ z.string().default("") > fail: false -> undefined
✓ z.string().default("") > fail: 0 -> undefined
✓ z.string().default("") > pass: "" -> ""
✓ z.string().default("") > fail: [] -> undefined
✓ z.string().default("") > fail: {} -> undefined
✓ z.string().default("") > fail: NaN -> undefined
✓ z.string().optional().default("") > pass: undefined -> ""
✓ z.string().optional().default("") > fail: null -> undefined
✓ z.string().optional().default("") > fail: true -> undefined
✓ z.string().optional().default("") > fail: false -> undefined
✓ z.string().optional().default("") > fail: 0 -> undefined
✓ z.string().optional().default("") > pass: "" -> ""
✓ z.string().optional().default("") > fail: [] -> undefined
✓ z.string().optional().default("") > fail: {} -> undefined
✓ z.string().optional().default("") > fail: NaN -> undefined
✓ z.string().nullable().default("") > pass: undefined -> ""
✓ z.string().nullable().default("") > pass: null -> null
✓ z.string().nullable().default("") > fail: true -> undefined
✓ z.string().nullable().default("") > fail: false -> undefined
✓ z.string().nullable().default("") > fail: 0 -> undefined
✓ z.string().nullable().default("") > pass: "" -> ""
✓ z.string().nullable().default("") > fail: [] -> undefined
✓ z.string().nullable().default("") > fail: {} -> undefined
✓ z.string().nullable().default("") > fail: NaN -> undefined
✓ z.string().nullable().default(null) > pass: undefined -> null
✓ z.string().nullable().default(null) > pass: null -> null
✓ z.string().nullable().default(null) > fail: true -> undefined
✓ z.string().nullable().default(null) > fail: false -> undefined
✓ z.string().nullable().default(null) > fail: 0 -> undefined
✓ z.string().nullable().default(null) > pass: "" -> ""
✓ z.string().nullable().default(null) > fail: [] -> undefined
✓ z.string().nullable().default(null) > fail: {} -> undefined
✓ z.string().nullable().default(null) > fail: NaN -> undefined
※ ちなみにz.string().default(null)は型エラーになるので試していない(「string型にnullを代入する」の意なのでそりゃそうかというところ)
これを見ていて分かることは、
.optional()が通すfalsyな値はundefinedのみ、.nullable()はnullのみ、.nullish()はnullとundefined
このあたりは「確認しなくても言葉の定義どおりだろ」と思われそうだが、長年JSを触っておきながら初めてしっかりと認識したところ。。。
.default()が変換を行うのはundefinedのみだし、undefinedを通してもバリデーションが通るということなのでz.string().optional().default("")は冗長な書き方、ということ
「prismaにundefinedを渡さないようにする」の観点で使える/使えないスキーマの判断
✕ z.string().optional() undefinedがundefinedとして返されてしまう
✕ z.string().nullish() undefinedがundefinedとして返されてしまう
◯ z.string().nullable() undefinedはバリデーションエラーにする方針
◯ z.string().default("") undefinedを空文字に変換する
✕ z.string().default(null) 型エラーなので調べてない
△ z.string().optional().default("") 挙動的には「undefinedを空文字に変換する」だが書き方が冗長
△ z.string().nullable().default("") undefinedが空文字に、nullはそのままnullなので、なんか混乱しそう
◯ z.string().nullable().default(null) undefinedもnullも統一してnullとして扱えるので↑より良い感じ
# まとめ
ということで、今回の要件で言うとz.string().nullable() / z.string().default("") / z.string().nullable().default(null)の3つを、何を通し何をエラーにさせたいかによって使い分けていく、という方針になりそう。
色々気づきが得られたと書いたが、「vitestをテスト目的以外で使う方法を思いついた」というのも、一つ大きな気付きだった。
おわり。
# vitestのコード
ちなみにこれ、ほぼcopilotが書いてます。
(最初のブロックでdescribeとitのテストケース文字列だけちゃんと書いたら、後は芋づる式で勝手に書いてくれた感じ)
code: typescript
import { z } from "zod"
describe("z.string().optional()", () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().optional(),
})
})
it("pass: undefined -> undefined", () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(true)
expect(resultUndefined.data?.whatever).toEqual(undefined)
})
it("fail: null -> undefined", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(false)
expect(resultNull.data?.whatever).toEqual(undefined)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})
describe("z.string().nullish()", () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().nullish(),
})
})
it("pass: undefined -> undefined", () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(true)
expect(resultUndefined.data?.whatever).toEqual(undefined)
})
it("pass: null -> null", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(true)
expect(resultNull.data?.whatever).toEqual(null)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})
describe("z.string().nullable()", () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().nullable(),
})
})
it("fail: undefined -> undefined", () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(false)
expect(resultUndefined.data?.whatever).toEqual(undefined)
})
it("pass: null -> null", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(true)
expect(resultNull.data?.whatever).toEqual(null)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})
describe('z.string().default("")', () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().default(""),
})
})
it('pass: undefined -> ""', () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(true)
expect(resultUndefined.data?.whatever).toEqual("")
})
it("fail: null -> undefined", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(false)
expect(resultNull.data?.whatever).toEqual(undefined)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})
describe('z.string().optional().default("")', () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().optional().default(""),
})
})
it('pass: undefined -> ""', () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(true)
expect(resultUndefined.data?.whatever).toEqual("")
})
it("fail: null -> undefined", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(false)
expect(resultNull.data?.whatever).toEqual(undefined)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})
describe('z.string().nullable().default("")', () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().nullable().default(""),
})
})
it('pass: undefined -> ""', () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(true)
expect(resultUndefined.data?.whatever).toEqual("")
})
it("pass: null -> null", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(true)
expect(resultNull.data?.whatever).toEqual(null)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})
describe("z.string().nullable().default(null)", () => {
let schema: z.Schema
beforeEach(() => {
schema = z.object({
whatever: z.string().nullable().default(null),
})
})
it("pass: undefined -> null", () => {
const resultUndefined = schema.safeParse({
whatever: undefined,
})
expect(resultUndefined.success).toBe(true)
expect(resultUndefined.data?.whatever).toEqual(null)
})
it("pass: null -> null", () => {
const resultNull = schema.safeParse({
whatever: null,
})
expect(resultNull.success).toBe(true)
expect(resultNull.data?.whatever).toEqual(null)
})
it("fail: true -> undefined", () => {
const resultTrue = schema.safeParse({
whatever: true,
})
expect(resultTrue.success).toBe(false)
expect(resultTrue.data?.whatever).toEqual(undefined)
})
it("fail: false -> undefined", () => {
const resultBoolean = schema.safeParse({
whatever: false,
})
expect(resultBoolean.success).toBe(false)
expect(resultBoolean.data?.whatever).toEqual(undefined)
})
it("fail: 0 -> undefined", () => {
const resultNumber = schema.safeParse({
whatever: 0,
})
expect(resultNumber.success).toBe(false)
expect(resultNumber.data?.whatever).toEqual(undefined)
})
it('pass: "" -> ""', () => {
const resultEmpty = schema.safeParse({
whatever: "",
})
expect(resultEmpty.success).toBe(true)
expect(resultEmpty.data?.whatever).toEqual("")
})
it("fail: [] -> undefined", () => {
const resultArray = schema.safeParse({
whatever: [],
})
expect(resultArray.success).toBe(false)
expect(resultArray.data?.whatever).toEqual(undefined)
})
it("fail: {} -> undefined", () => {
const resultObject = schema.safeParse({
whatever: {},
})
expect(resultObject.success).toBe(false)
expect(resultObject.data?.whatever).toEqual(undefined)
})
it("fail: NaN -> undefined", () => {
const resultNaN = schema.safeParse({
whatever: Number.NaN,
})
expect(resultNaN.success).toBe(false)
expect(resultNaN.data?.whatever).toEqual(undefined)
})
})