TL;DR
In our projects, we use AdminJS, an external library that provides a GUI for managing database records. It's a great tool for rapidly creating CRUD interfaces for our clients. However, we ran into some issues with poorly typed code inside the library that made it difficult to integrate with our own custom logic. This is where Zod, a validation library, came to the rescue.
Zod enabled us to create robust type definitions and validate data against those definitions. This helped us to avoid runtime errors and catch issues early in development. In this article, we'll take a look at how Zod helped us to deal with the poorly typed code in AdminJS and how it improved the overall quality of our code.
Challenges with Poorly Typed External Library
When using external libraries, it's not uncommon to run into issues with poorly typed code. This was the case with AdminJS, a library that we used in our projects to provide a GUI for managing database records. While AdminJS is a great tool for rapidly creating CRUD interfaces for our clients, it also presented some challenges.
One of these challenges was creating schemas for the objects that we received from AdminJS. The library provides a Record<string, any>
type, which is not particularly useful when it comes to type safety. We needed to define more robust type definitions for our data to avoid runtime errors.
This is where Zod, a validation library, came into play. We used Zod to create schemas for the objects that we received from AdminJS, which enabled us to catch errors early in development and avoid issues in production.
The code snippet above demonstrates how we defined a schema for a dashboard object that we received from AdminJS. We used Zod's object
method to create an object schema with three properties: title
, project
, and type
. We also used Zod's nativeEnum
method to create an enumeration schema for the type
property, which accepts two specific string values.
By creating these schemas with Zod, we were able to define the exact shape of the data we expected to receive, and catch any errors if the data did not match that shape. This helped us to ensure the quality of our code and avoid any issues caused by the poorly typed code in AdminJS.
import { z } from 'zod';
enum DashboardProviderType {
GRAFANA = "GRAFANA",
GOOGLE_STUDIO = "GOOGLE_STUDIO"
}
const DashboardProviderTypeEnum = z.nativeEnum(DashboardProviderType);
const DashboardPayloadSchema = z.object({
title: z.string(),
project: z.string(),
type: DashboardProviderTypeEnum,
});
Creating SubSchemas to Handle Specific Data
In the previous section, we defined a schema for the entire payload that we receive from AdminJS. However, in some cases, we might only be interested in a specific part of the payload, or we might not need all the fields from the payload. In such cases, it is useful to create sub-schemas, which define a subset of the original schema.
ZOD provides several methods to create sub-schemas, such as pick, omit, and partial.
In the code snippet provided, we are using these methods to create three different sub-schemas from the original DashboardPayloadSchema:
- DashboardTypeSchema: This schema picks the 'type' field from the DashboardPayloadSchema and creates a new schema with only that field.
- DashboardPartialSchema: This schema creates a partial schema from the DashboardPayloadSchema, which means that all the fields are optional.
- DashboardWithoutTypeSchema: This schema omits the 'type' field from the DashboardPayloadSchema and creates a new schema with only the 'title' and 'project' fields.
Creating sub-schemas can be useful when we want to validate only a part of the object, or when we want to reuse some of the fields in a different schema. It can also help in simplifying the validation logic and making it more readable.
const DashboardTypeSchema = DashboardPayloadSchema.pick({ type: true });
/*
{
type: DashboardProviderType;
}
*/
const DashboardPartialSchema = DashboardPayloadSchema.partial()
/*
{
type?: DashboardProviderType | undefined;
title?: string | undefined;
project?: string | undefined;
}
*/
const DashboardWithoutTypeSchema = DashboardPayloadSchema.omit({ type: true });
/*
{
title: string;
project: string;
}
*/
Refining Schemas with Custom Validation
One of the key features of Zod is the ability to refine schemas with custom validation logic. The refine
method can be used to add validation rules to a schema, allowing you to ensure that data meets specific requirements before it is processed.
In the following code snippet, we use refine
to ensure that the title
field in our DashboardPayloadSchema
is no longer than 255 characters. If the title is too long, Zod will throw an error with a custom message.
const DashboardTitle = z.string().refine((title) => title.length < 255, {
message: "Title cannot be longer than 255 chars"
});
const DashboardPayloadSchema = z.object({
title: DashboardTitle,
project: DashbaordProject,
type: DashboardProviderTypeEnum
});
The refine
method can also accept asynchronous functions, as shown in the following example. Here, we use refine
to ensure that the project
field in our schema exists in a database. If the project does not exist, Zod will throw an error with a custom message.
const DashboardProject = z.string().refine(async (projectName) => {
const project = await getProject(projectName);
return !!project;
}, {
message: "Project must exist in database!"
});
const DashboardPayloadSchema = z.object({
title: DashboardTitle,
project: DashbaordProject,
type: DashboardProviderTypeEnum
});
Modifying Validated Values with transform()
While validation ensures that the received data conforms to the schema, sometimes you might need to transform the data to a different format or structure. For example, you might need to extract a certain substring from a string or make a database call to fetch additional data based on a received value.
Zod provides the transform()
method that allows you to modify the validated values before returning them. The transform()
method accepts a synchronous or asynchronous function that takes the validated value and returns the transformed value.
In the following code snippet, we define a DashboardProject
schema that refines the received project string to ensure that it starts with the "PR_" prefix. Then, we use the transform()
method to remove the prefix before returning the value.
const DashboardProject = z.string()
.refine((project ) => project.startsWith("PR_"), {
message: "Project must start with 'PR_' prefix"
})
.transform((project) => project.replace("PR_", ""));
DashboardProject.parse("PR_MT"); // => "MT"
You can also use an asynchronous function with transform()
to perform more complex operations, such as making a database call:
const DashboardProject = z.string()
.refine((project) => project.startsWith("PR_"), {
message: "Project must start with 'PR_' prefix"
})
.transform(async (project) => {
const projectObject = await getProjectObjectFromDB(project);
return projectObject;
});
In the above example, the getProjectObjectFromDB()
function is an asynchronous function that fetches the project object from the database based on the received project string. The transform()
method applies this function to the validated value and returns the result.
Inferring Types and Creating Type Guards
type Dashboard = z.infer<typeof DashboardPayloadSchema>;
// equivalent to:
type Dashboard = {
title: string;
project: string;
type: DashboardProviderType;
};
export const isDashboard = (payload: unknown): payload is Dashboard => {
return DashboardPayloadSchema.safeParse(payload).success;
};
In the above code snippet, the infer
method is used to automatically infer the type of the schema defined by DashboardPayloadSchema
. The inferred type is then assigned to a type alias called Dashboard
. This allows us to use the inferred type throughout our codebase without having to manually define it.
Next, the code exports a type guard function called isDashboard
. A type guard is a function that checks if a value is of a certain type at runtime. In this case, the isDashboard
function checks if the provided payload
conforms to the Dashboard
type, by attempting to parse the payload
using the DashboardPayloadSchema
. If the parsing succeeds, the function returns true, indicating that the payload
is a valid Dashboard
. If the parsing fails, the function returns false.
Using type guards like isDashboard
can help catch type errors at runtime and make our code more robust, especially when working with data from external sources like APIs or databases where the shape of the data may not be known in advance.
Summary
In this article, we explored how Zod, a validation library, came to the rescue when integrating AdminJS, an external library with poorly typed code. AdminJS provides a Record<string, any>
type, leading to type safety issues. Zod helped us create robust type definitions and validate data against those definitions, catching errors early in development.
We defined a schema for a dashboard object using Zod's object
and nativeEnum
methods, ensuring the expected data shape. We also created sub-schemas with pick
, omit
, and partial
for specific parts of the payload. Custom validations were added with the refine
method to enforce requirements like string length and database existence.
Zod's transform
method allowed us to modify validated values, and we learned how to infer types using infer
, creating type guards to catch type errors at runtime. Overall, Zod improved code quality and reduced runtime errors, making our integration with AdminJS more efficient and reliable.
Top comments (0)