zod
zod copied to clipboard
New ZodDefaultOnMismatch class for providing a default value when there is a type mistmatch
Summary
A new class ZodDefaultOnMismatch which will replace the data with the default value when the provided value is a mismatch in type with the expected value. When data is undefined, ZodDefaultOnMismatch acts like ZodDefault.
const deleteAllFiles = z.boolean().defaultOnMismatch(false).parse("monkeys") // deleteAllFiles = false
Below, I layout two real world use cases. Whether or not this gets accepted, we will need this functionality, and I am hoping that we don't have to work off a forked version of zod.
Below, I also layout three implementations. All are tested and ready to be merged. I just need @colinhacks to tell me which is a sounder implementation, so I know which branch to make a pull request. Also, if the naming should be changed, I am up for that as well.
The implementation I suggest is the first one: https://github.com/seancrowe/zod/commit/40579cacf411f37a0458dd1abf85b8aba679fb81
So, if you don't feel like reading through all these implementations, the first one is the simplest and logical choice at the expense of JavaScript users requiring discipline.
Use Case
Use Case 1
Application is pulling data from a JSON file where both the data's type is not guaranteed to be correct or even defined. Our application wants to use zod to verify the parsed JSON, but we don't want to fail if there is a type mismatch, but instead default to a safe value.
Use Case 2
Our application uses an FP technique of passing data around as objects. This data is passed to a function, the function will copy the data, modify the data, and potentially add more data. Due to JavaScript being dynamic typed and TypeScript not having sound typing, the types defined by TypeScript are not guaranteed 100% at compile time. This scenario means that as we pass these data objects around, we cannot be guaranteed 100% that the data object is exactly as we expect.
To defend against this, a type check must be made everytime. If there is a type mismatch, we don't want to cause an error, but instead default to a safe value with the correct type. Since these objects can be complex or contain extra data our current function does not care about, we need make sure the input and output is untouched accept for the specific properties we are interested in.
ZodDefaultOnMismatch
Unfortunately, Zod does not have built way to support the two above use cases. Maybe it can be done with preprocess, but for our use case, that defeats the purpose of zod if every object property needs a handwritten type check anyway.
Instead, all zod needs is something like ZodDefault but that will use the default value not only when there is undefined but when there is a mismatch in types. Thus ZodDefaultOnMismatch.
A new class ZodDefaultOnMismatch which will replace the data with the default value when the provided value is a mismatch in type with the expected value. When data is undefined, ZodDefaultOnMismatch acts like ZodDefault.
Name
At first, I named this ZonDefaultAlways, but that implied it would always default to a value no matter what. ZodDefaultOnMismatch, while lengthy, is descriptive in what it does. However, I am not set on ZodDefaultOnMismatch. Maybe there is a better name?
Implementation
After tinkering, there were three options:
- 👍 Assume the value of the defaultValue is the same as the expected value and compare types
- Go down the innerType branch until we get to the end, and then compare the last innerType (excepted) to the current type
- Allow the running as normal, and if there is an abort, run again with the default type
The last two implementations require going down the inner branch twice.
First defaultValue type Implementaion
This is my suggestive implementation.
This implementation makes the assumption that the value from _def.defaultValue() will be the same type as the expected value. This is a safe assumption if the user is using TypeScript. If the user is using JavaScript, then they can pass whatever they want into the defaultValue. In my mind this an acceptable risk, as there are other areas already in zod, where JavaScripts lack of compiler type checking can break the result.
You can find this implementation here: https://github.com/seancrowe/zod/commit/40579cacf411f37a0458dd1abf85b8aba679fb81
(By the way, already made an improvement: https://github.com/colinhacks/zod/commit/354867bbebc437032f76750ac8a05e572cca8d00)
Second innerType Implementation
This implementation will use the _def to get the final last type to be parsed, grab the type of that and compare it to the parsed type.
You can find this implementation here: https://github.com/seancrowe/zod/commit/ee70583f9b18a86e4f25a05ad2b91e7f7745d215
This implementation had to solve two problems:
The first problem is that all the Zod classes do not share an interface that has innerType. Each class implements innerType separately. Since no common interface that means that either innerType is not always available or we would need to use a more generic "any" and hope innerType exists.
Actually, innerType is not always available. ZodEffect has a schema instead, which means you much go innerType -> schema -> innerType. This is one example, but if other Zod classes exist that use another naming convention, or future ones were added, we would end-up in the same scenario
The second problem is that the types are stored as Zod{type}, so "ZodString" or "ZodNumber" but our data type is stored as "string" or "number". This means that I would have to rely on the discipline of naming conventions to be able to compare "ZodString" to "string" or "ZodNumber" to "number".
Third abort Implementation
This implementation utilizes the fact that mismatch types are aborted, to then go down the parse function again but with the default value.
You can find this implementation here: https://github.com/seancrowe/zod/commit/1dd93075946436672771a25411a084a102f5da8e
This implementation feels less breaky at the expenses of having to carry around our defaultValue function in the ParseContext common. Meaning that defaultValue must not be readonly as it is set only after parsing begins.
The upside is that this will not break due to the problems listed above changing. For example, a new Zod class implemented that does not use innerType or schema, but something else altogether.
The downside of requiring parsing to happen twice at the fist ZodDefaultOnMismatch. It goes down the tree once, gets the abort and then goes down the tree again but with the value from ZodDefaultOnMismatch. I have to do this, because I don't know the type of the inner most value.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
Yeah this is not stale my bot friend because of me. I will make a pull request as the contribution suggests a discussion and with exception of thumbs 👍 up, there has been no discussion. So everyone must agree 😃.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
Did this ever get anywhere? It seems incredibly useful
@man-trackunit There is .catch()
now. See https://zod.dev/?id=catch.
Amazing, thanks! I only saw .default()
and it didn't quite cut it. The docs should maybe link to .catch()
from .default()
🤔 I don't think most people will search for "catch" when looking for something like this. Or maybe others are better at reading docs than me 🙈
The problem is, that catch
does not do what is described here. Let's have a simple schema with defaults like this
const PersonSchema = z.object({
address: z.object({
street: z.string().default('Trafalgar Square'),
city: z.string().default('London'),
}).default({}),
});
const person = PersonSchema.parse({});
This works fine. If address
is missing, its filled in with the defaults. But catch
does not work like that.
const PersonSchema = z.object({
address: z.object({
street: z.string().default('Trafalgar Square'),
city: z.string().default('London'),
}).catch(???),
});
const person = PersonSchema.parse({});
In order for this to work, you need to specify the value for the whole address
object, which duplicates the default values from the schema:
const PersonSchema = z.object({
address: z.object({
street: z.string().default('Trafalgar Square'),
city: z.string().default('London'),
}).catch({ street: 'Trafalgar Square', city: 'London' }),
});
Is there any way to say: "If the address
field is missing or it is of wrong type, fill in with the defaults"?
Thank you @velut, but this works the same as if there were default
calls instead of catch
calls on each field. When the address is completely missing in the input, it works fine. But when the address is e.g. a string, it fails. I would like to say that if the values in the address does no match the type in the schema, use the default value.
@podlomar Yeah, I deleted the comment after posting because I saw my error too.
I don't think there's an easy inline solution, the best way is probably to extract the defaults into an external variable like below:
import { z } from "zod";
const defaultAddress = Object.freeze({
street: "Trafalgar Square",
city: "London",
});
const PersonSchema = z.object({
address: z
.object({
street: z.string().catch(defaultAddress.street),
city: z.string().catch(defaultAddress.city),
})
.catch(defaultAddress),
});
const personWithInvalidAddress = PersonSchema.parse({ address: "invalid" });
console.log({ personWithInvalidAddress });
const personWithoutAddress = PersonSchema.parse({});
console.log({ personWithoutAddress });
const personWithPartialAddress = PersonSchema.parse({
address: {
street: 123,
city: "New York",
},
});
console.log({ personWithPartialAddress });
const personWithFullAddress = PersonSchema.parse({
address: {
street: "Via della Spiga",
city: "Milano",
},
});
console.log({ personWithFullAddress });
// Output:
// {
// personWithInvalidAddress: {
// address: {
// street: "Trafalgar Square",
// city: "London"
// }
// }
// }
// {
// personWithoutAddress: {
// address: {
// street: "Trafalgar Square",
// city: "London"
// }
// }
// }
// {
// personWithPartialAddress: {
// address: {
// street: "Trafalgar Square",
// city: "New York"
// }
// }
// }
// {
// personWithFullAddress: {
// address: {
// street: "Via della Spiga",
// city: "Milano"
// }
// }
// }
@velut yeah, I suspected something like that would be the solution. I believe though, that what I need could be a common usecase for this library.