01 – Runtime Type Checking with Zod
TypeScript is a very useful type tool for checking the type of variables in code
But we can’t always guarantee the type of variables in our code, such as those coming from API interfaces or form inputs.
The Zod library enables us to check the type of a variable at , which is useful for most of our projects!
First exploration runtime check
Check out this toString
function:
export const toString = (num: unknow) => {
return String(num)
}
We set the entry for num to unknow
This means that we can pass any type of parameter to the toString
function during the coding process, including object types or undefined :
toString('blah')
toString(undefined)
toString({name: 'Matt'})
So far no errors have been reported, but we want to prevent this from happening at
If we pass a string to toString
, we want to throw an error and indicate that a number was expected but a string was received
it("1111", () => {
expect(() => toString("123")).toThrowError(
"Expected number, received string",
);
});
If we pass in a number, toString
is able to function properly
it("2222", () => {
expect(toString(1)).toBeTypeOf("string");
});
prescription
Create a numberParser
The various parsers are one of the most basic features of Zod.
We use z.number()
to create a numberParser
It creates the z.ZodNumber
object, which provides some useful methods
const numberParser = z.number();
If the data is not of a numeric type, then passing it to numberParser.parse()
will result in an error.
This means that all variables passed into numberParser.parse()
will be converted to numbers before our test can pass.
Add numberParser
, update toString
method
const numberParser = z.number();
export const toString = (num: unknown) => {
const parsed = numberParser.parse(num);
return String(parsed);
};
Try different types
Zod also allows other type checks
For example, if the parameter we want to receive is not a number but a boolean value, then we can change numberParser
to z.boolean()
Of course, if we only change this, then our original test case will report an error!
This technique in Zod provides us with a solid foundation. As we get deeper into using it, you’ll find that Zod mimics a lot of what you’re used to in TypeScript.
The full base type of Zod can be viewed here
02 – Verifying Unknown APIs with Object Schema
Zod is often used to validate unknown API returns.
In the following example, we get information about a character from the Star Wars API
export const fetchStarWarsPersonName = async (id: string) => {
const data = await fetch("<https://swapi.dev/api/people/>" + id).then((res) =>
res.json(),
);
const parsedData = PersonResult.parse(data);
return parsedData.name;
};
Notice that the data now being processed by PersonResult.parser()
is coming from fetch requests
PersonResult
The variable was created by z.unknown()
, which tells us that the data is considered to be of type unknown
because we don’t know what’s contained in that data.
const PersonResult = z.unknown();
operational test
If we print out the fetch function’s return value at console.log(data)
, we can see that the API returns a lot of things, not just the name of the character, but also other things like eye_color, skin_color, and so on that we’re not interested in.
Next we need to fix the unknown type of this PersonResult
prescription
Use z.object
to modify PersonResult
First, we need to change PersonResult
to z.object
It allows us to define these objects using a key and a type.
In this example, we need to define name
to be the string
const PersonResult = z.object({
name: z.string(),
});
Notice that this is a bit like when we create an interface in TypeScript.
interface PersonResult {
name: string;
}
Check our work
In fetchStarWarsPersonName
, our parsedData
has now been given the correct type and has a structure that Zod recognizes
Recalling the API, we can still see that the returned data contains a lot of information that we’re not interested in.
Now if we print parsedData
with console.log
, we can see that Zod has already filtered out the Keys we’re not interested in for us, giving us only the name
field
Any additional keys added to PersonResult
will be added to parsedData
The ability to explicitly specify the type of each key in the data is a very useful feature in Zod.
03 – Creating Custom Type Arrays
In this example, we’re still using the Star Wars API, but this time we’re getting the data for the 所有
character
The beginning part is very similar to what we saw before, the StarWarsPeopleResults
variable is set to the z.unknown()
const StarWarsPeopleResults = z.unknown();
export const fetchStarWarsPeople = async () => {
const data = await fetch("https://swapi.dev/api/people/").then((res) =>
res.json(),
);
const parsedData = StarWarsPeopleResults.parse(data);
return parsedData.results;
};
Similar to before, adding console.log(data)
to the fetch function, we can see that there is a lot of data in the array even though we are only interested in the name field of the array.
If this were a TypeScript interface, it would probably be written like this
interface Results {
results: {
name: string;
}[];
}
Represent an array of StarWarsPerson
objects by updating StarWarsPeopleResults
with the object schema
You can refer to the documentation here for help.
prescription
The correct solution is to create an object to drink other objects. In this example, StarWarsPeopleResults
would be an object containing the results
attribute z.object
For results
, we use z.array
and provide StarWarsPerson
as a parameter. We also don’t need to rewrite the name: z.string()
section
This is the previous code
const StarWarsPeopleResults = z.unknown()
after modification
const StarWarsPeopleResults = z.object({
results: z.array(StarWarsPerson),
});
If we console.log
this parsedData
, we can get the desired data
Declaring an array of objects like the above is the most common use of z.array()
all the time, especially when the object has already been created.
04 – Extracting object types
Now we use the console function to print StarWarsPeopleResults to the console.
const logStarWarsPeopleResults = (data: unknown) => {
data.results.map((person) => {
console.log(person.name);
});
};
Once again, the type of data
is unknown
To fix it, one might try to use something like the following:
const logStarWarsPeopleResults = (data: typeof StarWarsPeopleResults)
However this will still be a problem because this type represents the type of the Zod object and not the StarWarsPeopleResults
type
Update logStarWarsPeopleResults
function to extract object types
prescription
Update this print function
Use z.infer
and pass typeof StarWarsPeopleResults
to fix the problem!
const logStarWarsPeopleResults = (
data: z.infer<typeof StarWarsPeopleResults>,
) => {
...
Now when we hover the mouse over this variable in VSCode, we can see that its type is an object containing results
When we update the schema StarWarsPerson
, the function’s data will be updated as well.
This is a great way to do type-checking at runtime using Zod, but also to get the type of the data at build time!
An alternative program
Of course, we can also save StarWarsPeopleResultsType as a type and export it from the file
export type StarWarsPeopleResultsType = z.infer<typeof StarWarsPeopleResults>;
logStarWarsPeopleResults
function would be updated to look like this
const logStarWarsPeopleResults = (data: StarWarsPeopleResultsType) => {
data.results.map((person) => {
console.log(person.name);
});
};
This way other files can also get the StarWarsPeopleResults
type if needed
05 – Making schema optional
Zod is equally useful in front-end projects
In this example, we have a function called validateFormInput
Here values
is of type unknown
, which is safe because we don’t know the fields of this form in particular. In this example, we have collected name
and phoneNumber
as the schema for the Form
object.
const Form = z.object({
name: z.string(),
phoneNumber: z.string(),
});
export const validateFormInput = (values: unknown) => {
const parsedData = Form.parse(values);
return parsedData;
};
As it stands now, our test will report an error if the phoneNumber field is not committed
Because phoneNumber is not always necessary, we need to come up with a scheme that allows our test cases to pass regardless of whether phoneNumber is submitted or not
prescription
In this case, the solution is very intuitive! Add .optional()
to the phoneNumber
schema and our test will pass!
const Form = z.object({ name: z.string(), phoneNumber: z.string().optional(), });
What we are saying is that the name
field is a required string, phoneNumber
may be a string or undefined
We don’t need to do anything extra, and making the schema optional is a very good solution!
06 – Setting Defaults in Zod
Our next example is much like the previous one: a form form input validator that supports optional values.
This time, Form
has a repoName
field and an optional array field keywords
const Form = z.object({
repoName: z.string(),
keywords: z.array(z.string()).optional(),
});
To make the actual form easier, we want to set it up so that we don’t have to pass in an array of strings.
Modify Form
so that when the keywords
field is empty, there is a default value (empty array)
prescription
Zod’s default schema function, which allows a field to be supplied with a default value if it is not passed a parameter.
In this example, we will use .default([])
to set up an empty array
keywords: z.array(z.string()).optional()
keywords: z.array(z.string()).default([])
Since we added the default value, we don’t need to use optional()
anymore, the optional is already included.
After the modification, our test can be passed
Inputs are different from outputs
In Zod, we’ve gotten to the point where the inputs are different from the outputs.
That is to say, we can do type generation based on inputs as well as outputs
For example, let’s create the types FormInput
and FormOutput
type FormInput = z.infer<typeof Form>
type FormOutput = z.infer<typeof Form>
present (sb for a job etc) z.input
As written above, the input is not exactly correct, because when we are passing parameters to validateFormInput
, we don’t have to necessarily pass the keywords
field
Instead of z.infer
, we can use z.input
to modify our FormInput.
If there is a discrepancy between the input and output of the validation function, it provides us with an alternative way of generating the type.
type FormInput = z.input<typeof Form>
07 – Clarification of permissible types
In this example, we will once again validate the form
This time, the Form form has a privacyLevel
field, which only allows the two types private
or public
.
const Form = z.object({
repoName: z.string(),
privacyLevel: z.string(),
});
If we were in TypeScript, we would write it like this
type PrivacyLevel = 'private' | 'public'
Of course, we could use the boolean type here, but if we need to add new types to PrivacyLevel
in the future, that would not be appropriate. It’s safer to use a union or enum type here.
The first test reports an error because our validateFormInput
function has values other than “private” or “public” passed into the PrivacyLevel
field.
it("2222", async () => {
expect(() =>
validateFormInput({
repoName: "mattpocock",
privacyLevel: "something-not-allowed",
}),
).toThrowError();
});
Your task is to find a Zod API that allows us to specify the string type of the incoming parameter as a way to get the test to pass.
prescription
Unions & Literals
For the first solution, we’ll use Zod’s union function and pass an array of “private” and “public” literals.
const Form = z.object({
repoName: z.string(),
privacyLevel: z.union([z.literal("private"), z.literal("public")]),
});
Literals can be used to represent: numbers, strings, boolean types; they cannot be used to represent object types.
We can use z.infer
to check the type of our Form
type FormType = z.infer<typeof Form>
In VS Code if you mouse over the FormType, we can see that privacyLevel
has two optional values: “private” and “public”.
What could be considered a more concise solution: enumeration
The same thing can be done by using the Zod enumeration via z.enum
as follows:
const Form = z.object({
repoName: z.string(),
privacyLevel: z.enum(["private", "public"]),
});
Instead of using syntactic sugar to parse literals, we can use the z.literal
This approach does not produce enumerated types in TypeScript such as
enum PrivacyLevcel {
private,
public
}
A new union type is created
Similarly, we can see a new union type containing “private” and “public” by hovering over the type
08 – Complex schema validation
So far, our form validator function has been able to check a variety of values
The form has a name, email field and optional phoneNumber and website fields.
However, we now want to make strong constraints on some values
Need to restrict users from entering illegitimate URLs and phone numbers
Your task is to find Zod’s API to do the validation for the form type
The phone number needs to be in the right characters, and the email address and URL need to be formatted correctly.
prescription
The strings section of the Zod documentation contains some examples of checksums that can help us pass the test with flying colors.
Now our Form form schema would look like this
const Form = z.object({
name: z.string().min(1),
phoneNumber: z.string().min(5).max(20).optional(),
email: z.string().email(),
website: z.string().url().optional(),
});
name
field is added to min(1)
because we can’t pass an empty string to it
phoneNumber
limits the string length to 5 to 20, and it is optional.
Zod has built-in mailbox and url validators, so we don’t need to manually write these rules ourselves.
You can notice that we can’t write .optional().min()
this way because the optional type doesn’t have a min
attribute. This means that we need to write .optional()
after each checker
There are many other checker rules that we can find in the Zod documentation
09 – Reducing Duplication by Combining Schemas
Now, let’s do something different.
In this example, we need to find solutions to refactor the project to reduce duplicate code
Here we have these schemas, including: User
, Post
and Comment
const User = z.object({
id: z.string().uuid(),
name: z.string(),
});
const Post = z.object({
id: z.string().uuid(),
title: z.string(),
body: z.string(),
});
const Comment = z.object({
id: z.string().uuid(),
text: z.string(),
});
We see that the id is present in every schema.
Zod provides a number of options for organizing object objects into different types, allowing us to make our code more compliant with the DRY
principle
Your challenge is that you need to refactor the code using Zod to reduce id rewriting
About Test Case Syntax
You don’t have to worry about the TypeScript syntax of this test case, here’s a quick explanation:
Expect<
Equal<z.infer<typeof Comment>, { id: string; text: string }>
>
In the code above, Equal
is confirming that z.infer<typeof Comment>
and {id: string; text: string}
are of the same type
If you remove the id
field from Comment
, then you can see in VS Code that Expect
will have an error reported because the comparison doesn’t hold up anymore
prescription
There are many ways we can refactor this code
For reference, here’s what we started with:
const User = z.object({
id: z.string().uuid(),
name: z.string(),
});
const Post = z.object({
id: z.string().uuid(),
title: z.string(),
body: z.string(),
});
const Comment = z.object({
id: z.string().uuid(),
text: z.string(),
});
Simple program
The simplest solution would be to extract the id
field and save it as a separate type, which could then be referenced by every z.object
const Id = z.string().uuid();
const User = z.object({
id: Id,
name: z.string(),
});
const Post = z.object({
id: Id,
title: z.string(),
body: z.string(),
});
const Comment = z.object({
id: Id,
text: z.string(),
});
That’s a pretty good solution, but the id: ID
segment still keeps repeating itself. All the tests pass, so that’s okay too
Using the Extend method
Another option is to create a base object called ObjectWithId
that contains the id
field
const ObjectWithId = z.object({
id: z.string().uuid(),
});
We can use the extension method to create a new schema to add the base object
const ObjectWithId = z.object({
id: z.string().uuid(),
});
const User = ObjectWithId.extend({
name: z.string(),
});
const Post = ObjectWithId.extend({
title: z.string(),
body: z.string(),
});
const Comment = ObjectWithId.extend({
text: z.string(),
});
Note that .extend()
overrides the field
Using the Merge method
Similar to the above scenario, we can use the merge method to extend the base object ObjectWithId
:
const User = ObjectWithId.merge(
z.object({
name: z.string(),
}),
);
Using .merge()
would be more verbose than .extend()
. We must pass a z.object()
object containing z.string()
Merging is usually used to unite two different types, not just to extend a single type
These are a few different ways to group objects together in Zod to reduce the amount of code duplication, make the code more DRY compliant, and make the project easier to maintain!
10 – Converting data by schema
prescription
As a reminder, this is what StarWarsPerson
looked like before the conversion:
const StarWarsPerson = z.object({
name: z.string()
});
Add a Transformation
When we have the name
field in .object()
, we can get the person
parameter and then convert it and add it to a new property
const StarWarsPerson = z
.object({
name: z.string(),
})
.transform((person) => ({
...person,
nameAsArray: person.name.split(" "),
}));
Inside .transform()
, person
is the object containing name
above.
This is also where we add the nameAsArray
attribute that satisfies the test.
All of this happens in the StarWarsPerson
scope, not inside the fetch
function or elsewhere.
another example
Zod’s conversion API applies to any of its primitive types.
For example, we can convert name
inside of z.object
const StarWarsPerson = z
.object({
name: z.string().transform((name) => `Awesome ${name}`)
}),
...
Now we have a name
field containing the Awesome Luke Skywalker
and a nameAsArray
field containing the ['Awesome', 'Luke', 'Skywalker']
The conversion process works at the bottom level, can be combined and is very useful