0
0
0
0
0
zod
validation
type-safety
error-management
backend
typescript

Writing a Parsing Utility For Zod

Zod is a great library, there's no doubt about that. However, sometimes you may find yourself dealing with repetitive boilerplate code that the library may generate. The most common situation I've noticed is the preparation for parsing the schema and reading the validation result. This is especially prevalent on the backend side, where most validation occurs. Without a utility, you need to produce and repeat the following code:

// It's just a part of a larger application code (but repetitive).
try {
  await schema.strict().parseAsync(payload);
  // Do some logic...
} catch (err) {
  // Do some logic...
  logger.error(`Error occurred in ${name}`);
  logger.error(err);
  throw errors.invalidSchema(name);
}

If this code is duplicated in one or two files, it's not a problem. However, if duplicated in more than five places, it starts to become a warning sign. The typical parsing mechanism always looks similar in the context of any application. It involves:

  1. Schema to validate.
  2. Validating the schema.
  3. If an error occurs, parsing the error and throwing it.
  4. If no error, doing nothing or returning values.

Additionally, when performing validation, I try to be consistent. If the code is repeated across n files, there is a possibility that I may forget to use the strict or parseAsync function. You may not need them at all, but I mention them in the context of consistency, which might otherwise not be achieved.

With all this in mind, let's write a small utility for Zod parsing to remove some boilerplate and repetitiveness.

Implementation Of The Parse Function

Let's design the contract of the function first. We want to have the following, easy-to-use signature.

const schema = z.object({
  id: z.string(),
});
// In this case, it should throw an error.
const result = parse(schema, { id: 1 });
// In this case, it will return an object, correctly typed by "Zod".
const result1 = parse(schema, { id: "1" });

Now parse function implementation.

// @@@ parse.ts
import { z, AnyZodObject } from 'zod';
import { errors } from './errors';

// Validation of the passed generic schema 
// if it matches the Zod schema object.
const parse = async <TSchema extends AnyZodObject>(
  schema: TSchema,
  payload: unknown,
): Promise<z.infer<TSchema>> => {
  try {
    // We're adding "strict()" and "parseAsync()" to every 
    // call and staying consistent.
    const result = await schema.strict().parseAsync(payload);
    return result;
  } catch (e: unknown) {
    // If an error occurs, we're using a common 
    // formatting utility. 
    throw errors.schema(e);
  }
};

export { parse };

First of all, we've created a generic type TSchema that must at least have the shape of AnyZodObject, imported from the Zod library. Then, we've passed a payload, which is really important here and is of type unknown. But why? When dealing with backend stuff, you don't have any guarantee that the passed object from the frontend is the type of object you expect. Frontend developers may pass anything, so it's naive to try typing it another way. The only valid option is to check it at runtime, and after doing so, we achieve type safety.

Type safety ensures that the types defined at compile time are strictly enforced at runtime, preventing type errors and ensuring consistent behavior. You can read more about this topic in the following article: Why you should start using Zod.

Secondly, we wrapped all validation code in a repetitive try, catch block. Next, we've executed the validation, and we're returning the parsed values. If parsing fails, an exception will be thrown, and then we're parsing this exception object with the errors.schema utility function (I'll explain it in a second). The error is also of type unknown to force us, developers, to perform additional checks (type guards) before reading any information from such an object.

Third, we're doing type inference with z.infer<TSchema> to produce a nice type for the consumer of this utility.

Lastly, here's a simple utility file errors.ts that contains the parsing logic for error objects:

import { https } from 'firebase-functions';
import { z } from 'zod';

const error = (
  code: https.FunctionsErrorCode,
  symbol: string,
  content: unknown,
): https.HttpsError =>
  // It's an error object from Firebase, 
  // but it can be anything else depending on the tech stack.
  new https.HttpsError(
    code,
    JSON.stringify({
      symbol,
      content,
    }),
  );

const errors = {
  internal: (content = `Something went wrong`) =>
    error(`internal`, `internal`, content),
  schema: (e: unknown) => {
    // Checking if the error is really a "Zod" error.
    if (e instanceof z.ZodError) {
      return error(
        `invalid-argument`,
        `invalid-schema`,
        // Mapping errors to the data supported by the frontend.
        e.errors.map(({ message, path }) => ({ message, key: path[0] })),
      );
    }

    return errors.internal();
  },
};

export { errors };

This file is responsible for typical error maintenance in the app. It may vary based on the tech stack you're using; in this example, we're returning Firebase error objects with content from Zod library.

Usage and Comparison

Now, instead of having a lot of duplicated code, we can use a simple function to handle the parsing logic.

const before = async (payload: unknown) => {
  const schema = z.object({
    id: z.string(),
  });

  try {
    const result = await schema.strict().parseAsync(payload);
    // Do some logic...
  } catch (e: unknown) {
    throw errors.schema(e);
  }
};
const after = async (payload: unknown) => {
  const schema = z.object({
    id: z.string(),
  });

  const result = await parse(schema, payload);
};

The entire validation algorithm, parsing, and type inference are encapsulated in a single function. We've removed repetition and ensured the consistency of the validation mechanism. Now, all parsing involves a simple strict call and ensures that it uses parseAsync to boost performance slightly.

This approach really shines when you consider the amount of code you avoid writing and maintaining, especially when you have 10+ endpoints or similar use cases. As I mentioned at the beginning, it's not worth considering such facades for something that is not repeated multiple times and annoying to work with.

Summary

Today we've created a useful utility function, parse, which is an implementation of the facade pattern. We've encapsulated some repetitive logic within a separate module. Instead of leaking this logic into every piece of application code, we now simply call the utility function and achieve the expected outcome.

The most important aspects to remember after reading this article are:

  1. If you have repetitive logic, wrap it into a facade and evaluate the benefits it provides.
  2. We've learned how to maintain and utilize built-in Zod generics.
  3. We've learned how to validate and parse errors using Zod.

Want to learn more about the facade pattern? Check out The use case for facade pattern article.

Author avatar
About Authorpraca_praca

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.