DEV Community

Shahar Kedar
Shahar Kedar

Posted on • Updated on

Union Types

In previous chapters I used pretty rudimentary schemas that involved mostly primitives. In reality our code is going to be much more complex, requiring complicated schemas to represent possible input.

The next few chapters will deep dive into some more complex schema examples. We will start with union types.

What are union types good for?

Union types are used to express a value that can be one of several types. There are many examples for the usefulness of union types:

  • A function parameter that can be either a string or a number can be defined using a union type as string | number. This is especially useful when dealing with values that might come from different sources or in different formats but can be handled by the same piece of code.
function formatAmount(amount: string | number) {}
Enter fullscreen mode Exit fullscreen mode
  • They are ideal for modeling data structures that might hold different types under different conditions. For example, we might have a response from an API that can either be an object representing data or an error message string. In fact, we can see this pattern in Zod's safeParse API:
safeParse(data: unknown): SafeParseSuccess<Output> | SafeParseError<Input>;
Enter fullscreen mode Exit fullscreen mode
  • We can achieve type structure polymorphism with union types. This is very powerful combined with type narrowing, enabling a form of runtime type inspection and branching logic that resembles polymorphic behavior in traditional OOP, where different code paths can be taken based on the actual type of an object.
type Animal = Dog | Cat | Bird;
type Dog = { type: "dog"; bark: boolean };
type Cat = { type: "cat"; meow: boolean };
type Bird = { type: "bird"; chirp: boolean };

function makeSound(animal: Animal): string {
  switch (animal.type) {
    case "dog":
      return animal.bark ? "woof" : "whimper";
    case "cat":
      return animal.meow ? "meow" : "purr";
    case "bird":
      return animal.chirp ? "chirp" : "tweet";
  }
}
Enter fullscreen mode Exit fullscreen mode

Defining union schemas with Zod

Just like with types, we can create schemas which are a union of two or more schemas:

const StringOrNumber = z.union([z.string(), z.number()]);
Enter fullscreen mode Exit fullscreen mode

For convenience, you can also use the .or method:

const StringOrNumber = z.string().or(z.number);
Enter fullscreen mode Exit fullscreen mode

Unions are not reserved only to primitive types. You can easily create a union of objects:

const Success = z.object({ value: z.string() });
const Error = z.object({ message: z.string() });
const Result = z.union([Success, Error]);
Enter fullscreen mode Exit fullscreen mode

Discriminated unions

Zod unions are useful but not very efficient. When we evaluate a value against a union schema, Zod will try to use all options to parse it, which could become quite slow. Not only that, if it fails, it will provide all the error from all the validations.

For example, if we run the following code:

Result.parse({ foo: 1 });
Enter fullscreen mode Exit fullscreen mode

We will get the following error:

ZodError: [
  {
    "code": "invalid_union",
    "unionErrors": [
      {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "value"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      },
      {
        "issues": [
          {
            "code": "invalid_type",
            "expected": "string",
            "received": "undefined",
            "path": [
              "message"
            ],
            "message": "Required"
          }
        ],
        "name": "ZodError"
      }
    ],
    "path": [],
    "message": "Invalid input"
  }
]
Enter fullscreen mode Exit fullscreen mode

Discriminated unions provide a more efficient and user friendly way for parsing objects, by allowing us to define a discriminator key to determine which schema should be used to validate the input.

const Success = z.object({ status: z.literal("success"), value: z.string() });
const Error = z.object({ status: z.literal("error"), message: z.string() });
const Result = z.discriminatedUnion("status", [Success, Error]);
Enter fullscreen mode Exit fullscreen mode

In the above example, we use the status property to discriminate between different input types. Let's see what happens now when we try to validate against an invalid input:

Result.parse({ type: "success", foo: 1 });
Enter fullscreen mode Exit fullscreen mode

This time we get a concise error:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": [
      "value"
    ],
    "message": "Required"
  }
]
Enter fullscreen mode Exit fullscreen mode

Another fun part of using discriminated unions is that we can now use type narrowing to handle inputs:

const result = Result.parse(input);
switch(result.status) {
  case "success": console.log(result.value); break;
  case "error": console.error(result.message); break;
}
Enter fullscreen mode Exit fullscreen mode

A word of warning: while discriminated unions are very powerful, there's an ongoing discussion on whether discriminated unions should be deprecated and replaced with a different API.

Summary

Union types in TypeScript is a powerful way of expressing type variability. Zod enables us to define union schemas quite easily. We should consider using discriminated unions in Zod for better performance and user friendly errors.

In our next chapter we will explore schema extension.

Top comments (0)