colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
33.68k stars 1.17k forks source link

Detailed errors for union schema #117

Closed Invertisment closed 4 years ago

Invertisment commented 4 years ago

I'd like to have more information or even TS-like details for union errors (Add a flag to parse?):

My types:

export const T = z.union([
  A, B, C
])

So what I currently get from this is:

     Error: 1 validation issue(s)

  Issue #0: invalid_union at 
  Invalid input

What I'd like to get is something more similar to this:

'{ t: 1 }' is not assignable to parameter of type '{ a: number } | { b: number }'

Is this sensible|doable|easy|hard?

colinhacks commented 4 years ago

Simple answer:

You have access to all this error information already. Zod throws a special subclass of Error called ZodError that contains detailed error information in the .errors property:

import * as z from "zod";

try {
  z.union([z.object({ a: z.number() }), z.object({ b: z.number() })]).parse({ t: 1 });
} catch (err) {
  if (err instanceof z.ZodError) {
    console.log(JSON.stringify(err.errors, null, 2));
  }
}

This wil print:

[
  {
    "code": "invalid_union",
    "unionErrors": [
      {
        "errors": [
          {
            "code": "unrecognized_keys",
            "keys": [
              "t"
            ],
            "path": [],
            "message": "Unrecognized key(s) in object: 't'"
          },
          {
            "code": "invalid_type",
            "expected": "number",
            "received": "undefined",
            "path": [
              "a"
            ],
            "message": "Required"
          }
        ]
      },
      {
        "errors": [
          {
            "code": "unrecognized_keys",
            "keys": [
              "t"
            ],
            "path": [],
            "message": "Unrecognized key(s) in object: 't'"
          },
          {
            "code": "invalid_type",
            "expected": "number",
            "received": "undefined",
            "path": [
              "b"
            ],
            "message": "Required"
          }
        ]
      }
    ],
    "path": [],
    "message": "Invalid input"
  }
]

More complicated answer:

Error handling in Zod is complex enough that I split it into it's own README: https://github.com/vriad/zod/blob/master/ERROR_HANDLING.md

That guide explains how to do this. Getting the exact error information you need can feel a bit like spelunking, but I'm pretty sure this complexity is irreducible. Believe me, I've tried :)

Here's how to do what you want:

import * as z from '.';

try {
  z.union([z.string(), z.number()]).parse(true);
} catch (err) {
  // check for ZodError
  if (err instanceof z.ZodError) {
    // loop over suberrors
    for (const suberr of err.errors) {
      // check if suberror is an `invalid_union` error
      // inside the for loop, `suberr` will have additional 
      // properties specific to union errors (e.g. `unionErrors`)
      if (suberr.code === z.ZodErrorCode.invalid_union) {
        // suberr.unionErrors is an array of ZodErrors for each union component
        suberr.unionErrors; // ZodError[]
        console.log(suberr.unionErrors.map(e => e.message).join("\n"))
      }
    }
  }
}

If you don't want to write this error handling code every time you want to handle union errors you should write your own Error Map which lets you customize the message for every kind of error in Zod. You then pass your error map as a parameter to .parse(). Instructions for doing so are all explained in the guide. 👍

You can get a sense for all the error data available to you like this:

chrbala commented 4 years ago

It seems like this could be improved with a union resolver function as I proposed in https://github.com/vriad/zod/issues/100#issuecomment-667584554. As long as the input matches well enough (e.g. with a discriminator field) to be recognized as an element of the union in userland, the errors could be simplified to a single case instead of needing to resolve unionErrors within an invalid_union error type.

enum Number {
  FLOAT,
  INT,
}
const floatObj = z.object({
  kind: z.literal(Number.FLOAT),
  value: z.number(),
});
const intObj = z.object({
  kind: z.literal(Number.INT),
  value: z.number().refine(n => n % 1 === 0, "not_int"),
});

const resolver = (val: unknown) =>
  val && typeof val === 'object'
    ? val.kind === Number.FLOAT
      ? floatObj
      : intObj
    : null;

const union = z.union([floatObj, intObj], resolver);

const input = {
  kind: Number.INT,
  value: 5.5,
};

union.parse(input); // same result as intObj.parse(input);

So here, parsing input with into either the union or intObj schemas should result in:

Error: 1 validation issue(s)

  Issue #0: custom_error at value
  not_int
Invertisment commented 4 years ago

Thanks, I've found all the error info. It's good that it's there.

In my case there was no discriminator field and I decided to print out my errors in a nested way:

Screenshot at 2020-08-15 18-45-34_

Invertisment commented 4 years ago

I'm not sure what you mean by "irreducible". In my case I used recursive function with a very similar suberr.code === z.ZodErrorCode.invalid_union check. And what I did is simply concatenated all errors and checked them recursively. I also added space increases so that deeply nested errors would have some spacing. This way I'll have them printed even for deeply recursive types.

I think that it's entirely possible to have these messages translated to my initially desired TS-like form. To do this it's needed to have a string representation of the type and to print it into the error object's message/field OR add the type itself to use .toString() on it. This way it could be printed later.

colinhacks commented 4 years ago

Most people want the default error messages to be something that can be presented to an end-user (say, as an error message on a form input) so I'm not willing to change the default union message to something like '{ t: 1 }' is not assignable to parameter of type '{ a: number } | { b: number }'.

There's no way for me to stringify the full error payload in a way that makes everyone happy so I'm not going to try. Developers should decide for themselves how to generate error messages from the errors object. 👍

TamirCode commented 1 year ago

convenient syntax for random travelers:

z.union(
    [
        z.object({ postId: z.number(), parentCommentId: z.undefined() }),
        z.object({ postId: z.undefined(), parentCommentId: z.number() }),
    ],
    { errorMap: () => ({ message: "Either postId or parentCommentId is required." }) }
)
LightSeekerSC commented 7 months ago

I still feel like there should be a way to control the invalid union message by default. Thanks to @TamirCode for a workable current solution. Saved my day

seb-lean commented 1 month ago

I guess the ❤️ count on @TamirCode's suggestion indicate a possible feature request 😄