question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

What is a good strategy to get a nice (nicer) error for invalid discriminated unions

See original GitHub issue

First of all: thanks for creating zod it is an awesome library.

Consider the following schema (contrived examples of course), and test. When you run this the error message is really long and hard for humans to grok. Note the schema has a discriminated union (the field type on each object is the discriminating field):

import { z } from "zod";

const typeA = z.object({
    type: z.literal("A"),
    prop1: z.string() 
})

const typeB = z.object({
    type: z.literal("B"),
    prop2: z.string() 
})

const typeC = z.object({
    type: z.literal("C"),
    prop3: z.string() 
})

const typeD = z.object({
    type: z.literal("D"),
    prop4: z.string() 
})

const schema = z.object({ mytypes: z.union([typeA, typeB, typeC, typeD]).array() });

schema.parse({
    mytypes: [ 
        { type: "A", prop1: "foo" },
        { type: "D", bar: "BAR" } // misses prop4
    ]
});

The long and hard to read error message
ZodError: [
  {
    "code": "invalid_union",
    "unionErrors": [
      {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "A",
            "received": "D",
            "path": [
              "mytypes",
              1,
              "type"
            ],
            "message": "Expected A, received D"
          },
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "mytypes",
              1,
              "prop1"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      },
      {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "B",
            "received": "D",
            "path": [
              "mytypes",
              1,
              "type"
            ],
            "message": "Expected B, received D"
          },
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "mytypes",
              1,
              "prop2"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      },
      {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "C",
            "received": "D",
            "path": [
              "mytypes",
              1,
              "type"
            ],
            "message": "Expected C, received D"
          },
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "mytypes",
              1,
              "prop3"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      },
      {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "mytypes",
              1,
              "prop4"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      }
    ],
    "path": [
      "mytypes",
      1
    ],
    "message": "Invalid input"
  }
]
    at new ZodError (/private/tmp/zodtest/node_modules/zod/src/ZodError.ts:140:5)
    at handleResult (/private/tmp/zodtest/node_modules/zod/src/types.ts:72:19)
    at ZodObject.ZodType.safeParse (/private/tmp/zodtest/node_modules/zod/src/types.ts:184:12)
    at ZodObject.ZodType.parse (/private/tmp/zodtest/node_modules/zod/src/types.ts:162:25)
    at Object.<anonymous> (/private/tmp/zodtest/index.ts:25:8)
    at Module._compile (node:internal/modules/cjs/loader:1101:14)
    at Module.m._compile (/private/tmp/zodtest/node_modules/ts-node/src/index.ts:1371:23)
    at Module._extensions..js (node:internal/modules/cjs/loader:1153:10)
    at Object.require.extensions.<computed> [as .ts] (/private/tmp/zodtest/node_modules/ts-node/src/index.ts:1374:12)
    at Module.load (node:internal/modules/cjs/loader:981:32) {
  issues: [
    {
      code: 'invalid_union',
      unionErrors: [Array],
      path: [Array],
      message: 'Invalid input'
    }
  ],
  format: [Function (anonymous)],
  addIssue: [Function (anonymous)],
  addIssues: [Function (anonymous)],
  flatten: [Function (anonymous)]
}

I’ve read ERROR_HANDLING.md but I still struggle how to actually make a more proper error message (whithout a whole lot of ugly coding).

The error that I want to see is in the error message but hidden in the forest of other messages:

...
{
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "mytypes",
              1,
              "prop4"
            ],
            "message": "Required"
 }
...

What is the best strategy to handle this? Any tips/pointer appreciated.

I’m on Zod 3.11.6. My actual use case, a CLI app that parses a DSL yaml, has even more possible types in the union, so the error message is really long and hard to read.

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Reactions:1
  • Comments:9 (2 by maintainers)

github_iconTop GitHub Comments

3reactions
scotttrinhcommented, Nov 30, 2021

You could probably implement something like z.discriminatedUnion using z.custom that does something similar to these suggestions. I think it’s on the medium-term roadmap to support discriminated unions more directly since there is also a considerable speed increase with getting closer to constant-time evaluation here instead of linear.

1reaction
alexxandercommented, Jan 27, 2022

I implemented a function for handling discriminated unions (using zod 3.11.6). zodDiscriminatedUnion(discriminator: string, types: ZodObject[])

  • discriminator the name of the discriminator property
  • types an array of object schemas Its behaviour is very similar to that of the z.union() function. However, it only allows a union of objects, all of which need to share a discriminator property. This property must have a different value for each object in the union.

Jest tests and usage (zodDiscriminatedUnion.spec.ts):

import { z } from 'zod';

import { zodDiscriminatedUnion } from './zodDiscriminatedUnion';

describe('zodDiscriminatedUnion', () => {
  it('valid', () => {
    expect(() =>
      zodDiscriminatedUnion('type', [
        z.object({ type: z.literal('a'), a: z.string() }),
        z.object({ type: z.literal('b'), b: z.string() }),
      ]).parse({ type: 'a', a: 'abc' })
    ).not.toThrow();
  });

  it('invalid discriminator value', () => {
    expect.assertions(1);
    try {
      zodDiscriminatedUnion('type', [
        z.object({ type: z.literal('a'), a: z.string() }),
        z.object({ type: z.literal('b'), b: z.string() }),
      ]).parse({ type: 'x', a: 'abc' });
    } catch (e) {
      expect(JSON.parse(e.message)).toEqual([
        {
          code: z.ZodIssueCode.custom,
          message: 'Invalid discriminator value. Expected one of: a, b. Received x.',
          path: ['type'],
        },
      ]);
    }
  });

  it('valid discriminator value, invalid data', () => {
    expect.assertions(1);
    try {
      zodDiscriminatedUnion('type', [
        z.object({ type: z.literal('a'), a: z.string() }),
        z.object({ type: z.literal('b'), b: z.string() }),
      ]).parse({ type: 'a', b: 'abc' });
    } catch (e) {
      expect(JSON.parse(e.message)).toEqual([
        {
          code: 'invalid_type',
          expected: 'string',
          message: 'Required',
          path: ['a'],
          received: 'undefined',
        },
      ]);
    }
  });

  it('wrong schema - missing discriminator', () => {
    expect.assertions(1);
    try {
      zodDiscriminatedUnion('type', [
        z.object({ type: z.literal('a'), a: z.string() }),
        z.object({ b: z.string() }) as any,
      ]).parse({ type: 'x', a: 'abc' });
    } catch (e) {
      expect(e).toHaveProperty(
        'message',
        'zodDiscriminatedUnion: Cannot read the discriminator value from one of the provided object schemas'
      );
    }
  });

  it('wrong schema - duplicate discriminator values', () => {
    expect.assertions(1);
    try {
      zodDiscriminatedUnion('type', [
        z.object({ type: z.literal('a'), a: z.string() }),
        z.object({ type: z.literal('a'), b: z.string() }),
      ]).parse({ type: 'x', a: 'abc' });
    } catch (e) {
      expect(e).toHaveProperty('message', 'zodDiscriminatedUnion: Some of the discriminator values are not unique');
    }
  });
});

Implementation (zodDiscriminatedUnion.ts):

import { z } from 'zod';

const getDiscriminatorValue = (type: z.ZodObject<any, any, any>, discriminator: string) => {
  const shape = type._def.shape();
  return shape[discriminator].value;
};

/**
 * A constructor of a discriminated union schema. Its behaviour is very similar to that of the z.union() function.
 * However, it only allows a union of objects, all of which need to share a discriminator property. This property must
 * have a different value for each object in the union.
 * @param discriminator the name of the discriminator property
 * @param types an array of object schemas
 */
export const zodDiscriminatedUnion = <
  Discriminator extends string,
  TShape extends { [key in Discriminator]: z.ZodLiteral<any> } & z.ZodRawShape,
  T extends [z.ZodObject<TShape, any, any>, z.ZodObject<TShape, any, any>, ...z.ZodObject<TShape, any, any>[]]
>(
  discriminator: Discriminator,
  types: T
): z.ZodUnion<T> => {
  // Get all the valid discriminator values
  let validDiscriminatorValues: string[];
  try {
    validDiscriminatorValues = types.map((type) => getDiscriminatorValue(type, discriminator));
  } catch (e) {
    throw new Error(
      'zodDiscriminatedUnion: Cannot read the discriminator value from one of the provided object schemas'
    );
  }

  // Assert that all the discriminator values are unique
  if (new Set(validDiscriminatorValues).size !== validDiscriminatorValues.length) {
    throw new Error('zodDiscriminatedUnion: Some of the discriminator values are not unique');
  }

  return z.record(z.unknown()).superRefine((val: any, ctx) => {
    // Find the schema for the provided discriminator value
    const schema = types.find((type) => getDiscriminatorValue(type, discriminator) === val[discriminator]);

    if (schema) {
      try {
        schema.parse(val);
      } catch (e) {
        if (!(e instanceof z.ZodError)) {
          throw e;
        }
        for (const issue of e.issues) {
          ctx.addIssue(issue);
        }
      }
    } else {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: `Invalid discriminator value. Expected one of: ${validDiscriminatorValues.join(', ')}. Received ${
          val[discriminator]
        }.`,
        path: [discriminator],
      });
    }
  }) as any;
};
Read more comments on GitHub >

github_iconTop Results From Across the Web

What is a good strategy to get a nice (nicer) error for invalid ...
First of all: thanks for creating zod it is an awesome library. Consider the following schema (contrived examples of course), and test.
Read more >
Error message indicates incorrect union discriminant #37506
TypeScript Version: 3.8.3 (and Nightly) Search Terms: discriminated union incorrect discriminant union error message Code type A = { type: ...
Read more >
TypeScript error when using discriminated union type
I'm trying to use discriminated union type in TypeScript but I'm getting the following error: Type '{ data: Aaaa | Bbbb; type: "aaaa"...
Read more >
Error handling in TypeScript like a pro - Journal - Plain
At Plain, we created a type called DomainError which is a discriminated union of all possible errors that could happen in our domain....
Read more >
Best Practices for Using TypeScript and React - OneSignal
Anytime you reach for disjoint unions, pause and consider whether the single component should instead be separated into two. Accessible ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found