Taming Badly Typed External Libraries - How Zod Boosts Type Safety
Tags: javascript, typescript, validation, type
Published: 2023-07-31 10:07:39

## TL;DR

In our projects, we use [AdminJS](https://adminjs.co/), 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](https://zod.dev/), 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.

```jsx
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.

```jsx
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.

```jsx
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.

```jsx
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.

```jsx
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:

```jsx
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

```jsx
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](https://zod.dev/), a validation library, came to the rescue when integrating AdminJS, an external library with poorly typed code. [AdminJS](https://adminjs.co/) 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.