Union Types
Tags: typescript, node, zod, programming
Published: 2024-03-26 19:25:33

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.
```typescript
function formatAmount(amount: string | number) {}
```
- 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:
```typescript
safeParse(data: unknown): SafeParseSuccess<Output> | SafeParseError<Input>;
```
- We can achieve type structure polymorphism with union types. This is very powerful combined with [type narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html), 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. 
```typescript
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";
  }
}
```
## Defining union schemas with Zod
Just like with types, we can create schemas which are a union of two or more schemas:

```typescript
const StringOrNumber = z.union([z.string(), z.number()]);
```
For convenience, you can also use the `.or` method:
```typescript
const StringOrNumber = z.string().or(z.number);
```
Unions are not reserved only to primitive types. You can easily create a union of objects:

```typescript
const Success = z.object({ value: z.string() });
const Error = z.object({ message: z.string() });
const Result = z.union([Success, Error]);
```

## 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:

```typescript
Result.parse({ foo: 1 });
```

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"
  }
]
```

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.

```typescript
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]);
```
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:

```typescript
Result.parse({ type: "success", foo: 1 });
```
This time we get a concise error:
```
ZodError: [
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": [
      "value"
    ],
    "message": "Required"
  }
]
```
Another fun part of using discriminated unions is that we can now use type narrowing to handle inputs:

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

_A word of warning:_ while discriminated unions are very powerful, there's an [ongoing discussion](https://github.com/colinhacks/zod/issues/2106) 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.