zod icon indicating copy to clipboard operation
zod copied to clipboard

feat: add `decode` and `safeDecode` methods to check type at build time instead of runtime

Open julienfouilhe opened this issue 1 year ago • 3 comments

This adds the following methods:

  • decode
  • safeDecode
  • decodeAsync
  • safeDecodeAsync

Those methods are extremely similar to their parse counterparts, the differences are:

  • They expect a Input as an argument instead of unknown.

Using zod as such is therefore possible:

const schema = z.object({ k:  z.number().min(1) });

const valid = { k: 12 };
const invalidWrongType = "value";
const invalidValue = { k: 0 };
const invalidUnknown = { k: 12 } as unknown;

schema.decode(valid) // success
schema.decode(invalidWrongType) // Typescript error
schema.decode(invalidValue) // Runtime error
schema.decode(invalidUnknown) // Typescript error, should use .parse instead

Checks, refiners, transformers are still run, it just delegates type checking to typescript?

The main use case is when you want to apply business rules to multiple fields that can return an error, but primitives types have already been checked some other way, for instance:

  • Schema-first gRPC/graphql servers with generated stub files that check messages/queries already.
  • Databases accessed using some kind of ORM that already checks primitives.

For instance, I have a gRPC server and I want to transform the message { email: string; phoneNumber: string | null } into { email: EmailAddress; phoneNumber: PhoneNumber | null }

Without something like zod, it can be painful:

try {
  const email = EmailAddress.parse(request.email);
  const phoneNumber = request.phoneNumber ? PhoneNumber.parse(request.phoneNumber) : null;
  // rest of your code
} catch (e) {
  // handle error
}

It's even more painful when your parse methods are safe and do not throw.

  const email = EmailAddress.parse(request.email);
  const phoneNumber = request.phoneNumber ? PhoneNumber.parse(request.phoneNumber) : null;
  
  if (email instanceof Error || phoneNumber instanceof Error) {
    // handle error
  }

With zod and the decode methods, I can ensure that my zod schema matches my gRPC message, which results in cleaner and more reusable/composable pieces of code:

const schema = z.object({ 
  email: z.string().refine(/* ... */).transform(/* ... */),
  phoneNumer: z.string().refine(/* ... */).transform(/* ... */).nullable(), // see the typo here in the key?
})

const parsed = schema.safeDecode(request) // Typescript error, key phoneNumer is expected but received phoneNumber
if (!parsed.success) {
  // handle error
}

Some more info in this discussion https://github.com/colinhacks/zod/discussions/1335

I'm not too sure about:

  • Should we keep validating the type at runtime to prevent unexpected errors when the type of the input is any?
  • Are these changes enough, it seems like some structural changes could be good to reduce the number of paths.

julienfouilhe avatar Aug 17 '22 01:08 julienfouilhe

Deploy Preview for guileless-rolypoly-866f8a ready!

Built without sensitive environment variables

Name Link
Latest commit 23b297359a8c30507bca6d34b3074c00ef52ddc3
Latest deploy log https://app.netlify.com/sites/guileless-rolypoly-866f8a/deploys/63171c1dc6d89f0008fe2850
Deploy Preview https://deploy-preview-1342--guileless-rolypoly-866f8a.netlify.app
Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site settings.

netlify[bot] avatar Aug 17 '22 01:08 netlify[bot]

Sorry, I'm not really seeing the appeal of this. I get that it's somewhat duplicative, but I recommend using regular .parse here? It's weird to provide a method that skips some checks (type validations) but not others (refinements). This distinction is hard to explain to users who aren't familiar with Zod internals.

I suppose I'm not clear either on whether your concern here is performance or DX/API. It seems like you want to skip validations but apply transforms? You may be better suited just defining your transform logic externally to Zod in this case. I don't think this use case is popular enough to merit a major addition to the API.

colinhacks avatar Sep 04 '22 02:09 colinhacks

@colinhacks My only usecase is that parse expects an unknown object and sometimes it doesn't make sense. Sometimes you have a schema like this one: const dateTimeSchema = z.string().refine(externalDateTimeValidationFunction) and you want to check that something you already know is a string is valid. But dateTimeSchema.parse(3) throws a runtime error, when it could have been caught by typescript at build time if the decode method, added in this PR, existed.

Example of where it can be useful: You have two APIs that implement the same usecase and need the same data. You write a zod schema for them both:

z.object({
  id: z.string().refine(isUUID),
  prop1: z.string().nullable(),
})
  • a REST API with a body: unknown that you parse using a zod schema.
  • a gRPC API that gives you a message: { id: string, prop1: string | null } that you decode using the same zod schema. Advantage: If the type of message is not compatible with the zod schema input, there will be a typescript error, no risk this will be sent in production.

The removal of runtime validations checks can be reverted, I just did it because it felt like they weren't necessary with decode.

julienfouilhe avatar Sep 04 '22 14:09 julienfouilhe

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[bot] avatar Nov 05 '22 12:11 stale[bot]