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 anumber
can be defined using a union type asstring | 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) {}
- 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>;
- 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";
}
}
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()]);
For convenience, you can also use the .or
method:
const StringOrNumber = z.string().or(z.number);
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]);
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 });
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.
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:
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:
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 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)