Enhancing Redwood: A Guide to Implementing Zod for Data Validation and Schema Sharing Between the API and Web Layers
Tags: redwood, zod, react
Published: 2023-09-24 21:24:34


First things first: This walkthrough has been conducted on macOS. It uses symlinks, so I can't provide information about the setup on Windows.

## The Story

I'm currently experimenting with the fantastic [Redwood](https://redwoodjs.com/) framework. However, while going through the excellent [tutorial](https://redwoodjs.com/docs/tutorial/foreword), I didn't find any guidance on using data validation libraries like [Yup](https://github.com/jquense/yup/tree/pre-v1), [Zod](https://zod.dev/), [Vest](https://vestjs.dev/), etc. So, I had to do some investigation and came up with a solution. This article describes the implementation of validation with Zod in a fresh Redwood app. You can find the sources at this [github repository](https://github.com/growms/redwood-zod).

**Note**: I'm not a React, Yarn or even WebPack expert. I used to be an Angular developer, and I work with [Nx](https://nx.dev/) for workspace management.

## Setup

Let's begin with a brand new Redwood app:

```bash
# Create the app
yarn create redwood-app my-redwood-zod
cd my-redwood-zod
# Migrate the default schema "UserExample"
yarn rw prisma migrate dev
# Generate the CRUD for "UserExample"
yarn rw g scaffold UserExample
# Launch the development server
yarn redwood dev
```

## Sharing Code

Start by creating the basic structure and sharing a variable called "userExampleSchema," which contains a string (although its content may change in the near future):

```bash
# Create the folder to be shared
mkdir -p ./api/src/lib/common
# Create a zod.ts file with a basic variable
echo "export const userExampleSchema: string = \"I'm a shared value written in TypeScript!\"" > ./api/src/lib/common/zod.ts
# Add the symlink to be able to use the common libs on the "web" side
ln -s ../../../api/src/lib/common ./web/src/lib/common
```

Why symlink ? I have experimented with babel.config.js, tsconfig, webpack.config.js and just could not make it work properly, there was always drawbacks deploying or testing whatsoever. Once again i'm not a pro with yarn and webpack so if you have a better way feel free to share in comment !

We should ignore the symlink in `.gitignore` to avoid any code duplication:

```typescript
// .gitignore
...
web/src/lib/common
```

That's it! Now let's see if it works. Edit `UserExampleForm.tsx`:

```typescript
// web/src/components/UserExample/UserExampleForm/UserExampleForm.tsx

import { userExampleSchema } from 'src/lib/common/zod';
...
const UserExampleForm = (props: UserExampleFormProps) => {
  ...
  return (
    <div className="rw-form-wrapper">
      <h1>Shared Variable: {userExampleSchema}</h1>
      ...
    </div>
  );
};
export default UserExampleForm;
```

Check at [http://localhost:8910/user-examples/new](http://localhost:8910/user-examples/new)!

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vb17ylo2nrycsp2jvqnl.png)


One side done ! what about the server ? let’s edit `api/src/ervices/userExamples.ts` :

```typescript
import { userExampleSchema } from 'src/lib/common/zod'
...

export const userExamples: QueryResolvers['userExamples'] = () => {
  console.log('aSharedVar', userExampleSchema) // +
  return db.userExample.findMany()
}
...
```

Then go at [http://localhost:8910/user-examples](http://localhost:8910/user-examples) and look at logs you should see our string ! :


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6y57a73y0h5xxgxx69yr.png)

It seems to work ! but as we are “clean coders” we need to check that test is working too so let’s make a small one. Add a `zod.test.ts` file in the `common` directory to test our variable :

```typescript
import { userExampleSchema } from './zod'

describe.only('zod', () => {
  it('has userExampleSchema const', () => {
		expect(userExampleSchema).not.toBeUndefined()
  })
})
```

You can run test on Redwoodwith the command  `yarn rw test` then type `t` and `zod`

Sharing is done, let’s adding Zod validation.

## Zod validation
### Server Side

Now let's work on the server side. First, install `zod` in the `api` workspace:

```bash
cd api && yarn add zod && cd ..
```

Remove the reference to `userExampleSchema` in `UserExampleForm.tsx` to avoid unnecessary errors. Replace the old string with an actual Zod schema to validate our email and name:

```typescript
// api/src/lib/common/zod.ts
import { z } from 'zod';

export const userExampleSchema = z.object({
  email: z.string().min(1, { message: 'Email is required' }).email({
    message: 'Must be a valid email',
  }),
  name: z.string(),
});
```

To validate the data in a "Redwood way," we need to return a `RedwoodError`. If we want to have a nice field mapping, it should comply with the [ServiceValidationError](https://github.com/redwoodjs/redwood/blob/main/packages/api/src/validations/errors.ts#L6). Let's add our custom error and a `validateWithZod()` utility to use it in our services:

```typescript
// api/src/lib/zodValidation.ts
import { ZodError } from 'zod';
import { RedwoodError } from '@redwoodjs/api';

export class ZodValidationError extends RedwoodError {
  constructor(error: ZodError) {
    const { issues } = error;
    const errorMessage = 'Validation failed';

    const messages = {};

    const extensions = {
      code: 'BAD_USER_INPUT',
      properties: {
        messages,
      },
    };

    // Process each error and add it to messages object
    for (const { message, path } of issues) {
      path.forEach((pathItem) => {
        messages[pathItem] = messages[pathItem] || [];
        messages[pathItem].push(message);
      });
    }

    super(errorMessage, extensions);
    this.name = 'ZodValidationError';

    Object.setPrototypeOf(this, ZodValidationError.prototype);
  }
}

export const validateWithZod = (input: any, schema: any) => {
  const result = schema.safeParse(input);
  if (!result.success) {
    throw new ZodValidationError(result.error);
  }
};
```

All the hard work is done. Enjoy:

```typescript
// api/src/services/userExamples.ts
import { userExampleSchema } from 'src/lib/common/zod';
import { validateWithZod } from 'src/lib/zodValidation';
...
export const createUserExample: MutationResolvers['createUserExample'] = ({
  input,
}) => {
  validateWithZod(input, userExampleSchema);

  return db.userExample.create({
    data: input,
  });
};
```

Yep, it's just one method call!

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ygtbz7iykb3cfupdwg0z.png)

**Note**: Don't forget to update the related tests in `userExamples.test.ts` to ensure they pass.

```typescript
// api/src/services/userExamples.test.ts
...
scenario(
    'creates a userExample with valid email and non-empty name',
    async () => {
      // Test that email must be valid
      await expect(async () => {
        return await createUserExample({
          input: { email: 'String1484848', name: 'John' },
        });
      }).rejects.toThrow(ZodValidationError);

      // Test that email must not be empty
      await expect(async () => {
        return await createUserExample({
          input: { email: '', name: 'John' },
        });
      }).rejects.toThrow(ZodValidationError);

      // Test that name must not be empty
      await expect(async () => {
        return await createUserExample({
          input: { email: 'johndoe@example.com', name: '' },
        });
      }).rejects.toThrow(ZodValidationError);

      // Test with valid email and non-empty name
      const validResult = await createUserExample({
        input: { email: 'johndoe@example.com', name: 'John' },
      });

      // Assert that the result has the expected email and name
      expect(validResult.email).toEqual('johndoe@example.com');
      expect(validResult.name).toEqual('John');
    }
  )
...
```

Great! Our server is now robust. But we also need to take care of our users with l33t front-end validation.

### Client Side

To begin, we need to install dependencies in the `web` workspace:

```bash
cd web && yarn add zod @hookform/resolvers && cd ..
```

Now we want to use Zod in the generated `UserExampleForm`.

**Note**: Oh no! Redwood validation is so easy why should i change it !? Don't panic! Redwood validation is built on [React Hook Form](https://www.react-hook-form.com/), and we just installed a Zod resolver for it.

Adding Zod validation

```typescript
// UserExampleForm.tsx
import { zodResolver } from '@hookform/resolvers/zod';
...
import {
  ...
  useForm,
} from '@redwoodjs/forms';

const UserExampleForm = (props: UserExampleFormProps) => {
  const formMethods = useForm<FormUserExample>({
    resolver: zodResolver(userExampleSchema),
  });

  ...
  return (
    <div className="rw-form-wrapper">
      <Form<FormUserExample>
        onSubmit={onSubmit}
        error={props.error}
        formMethods={formMethods}
      >
        ...
      </Form>
    </div>
  );
};

export default UserExampleForm;
```

If you've gone through the Redwood tutorial you should recognize the `useForm()` method . It was used to reset the Article Form. You can see the Zod resolver is a very small addition to it, and that's all the hard work to make it work.
Please note that the `useForm` function is essentially a direct call to the method with the same name in [React Hook Form](https://www.react-hook-form.com/api/useform/). This means it's not a "Redwood thing" and if you want to know more about it, you should refer to RHF documentation!"

Now if you test the form, it should send the same errors than before, but they are coming from the client !
Hmm wait... how can i be sure it’s not the server which throws the error ? 
Well you can comment the line of `validateWithZod` in the service if you dare ! But you could also just check network in chrome dev tools or the server's logs  ;)

**ET VOILA** 
Now you have a Zod setup for both the client and server! 


Hope you learned something ! cheers