Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New ZodDefaultOnMismatch class for providing a default value when there is a type mistmatch #1227

Closed
seancrowe opened this issue Jun 26, 2022 · 10 comments
Labels
stale No activity in last 60 days

Comments

@seancrowe
Copy link
Contributor

seancrowe commented Jun 26, 2022

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: seancrowe@40579ca

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:
seancrowe@40579ca

(By the way, already made an improvement: 354867b)

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:
seancrowe@ee70583

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:
seancrowe@1dd9307

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.

@stale
Copy link

stale bot commented Aug 30, 2022

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.

@stale stale bot added the wontfix This will not be worked on label Aug 30, 2022
@seancrowe
Copy link
Contributor Author

seancrowe commented Aug 31, 2022

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 😃.

@stale stale bot removed the wontfix This will not be worked on label Aug 31, 2022
@stale
Copy link

stale bot commented Oct 31, 2022

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.

@stale stale bot added the stale No activity in last 60 days label Oct 31, 2022
@stale stale bot closed this as completed Nov 7, 2022
colinhacks pushed a commit that referenced this issue Nov 14, 2022
* Add 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.

* Update tests to new assertEqual method

* Add catch

* Add async test

* Fix default tests

* Remove unnecessary test file

* Update test

Co-authored-by: Colin McDonnell <colinmcd@alum.mit.edu>
@man-trackunit
Copy link

Did this ever get anywhere? It seems incredibly useful

@velut
Copy link

velut commented Nov 16, 2023

@man-trackunit There is .catch() now. See https://zod.dev/?id=catch.

@man-trackunit
Copy link

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 🙈

@podlomar
Copy link

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"?

@podlomar
Copy link

podlomar commented Nov 20, 2023

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.

@velut
Copy link

velut commented Nov 20, 2023

@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"
//     }
//   }
// }

@podlomar
Copy link

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
stale No activity in last 60 days
Projects
None yet
Development

No branches or pull requests

4 participants