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 framework. However, while going through the excellent tutorial, I didn't find any guidance on using data validation libraries like Yup, Zod, Vest, 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.
Note: I'm not a React, Yarn or even WebPack expert. I used to be an Angular developer, and I work with Nx for workspace management.
Setup
Let's begin with a brand new Redwood app:
# 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):
# 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:
// .gitignore
...
web/src/lib/common
That's it! Now let's see if it works. Edit UserExampleForm.tsx
:
// 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!
One side done ! what about the server ? let’s edit api/src/ervices/userExamples.ts
:
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 and look at logs you should see our string ! :
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 :
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:
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:
// 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. Let's add our custom error and a validateWithZod()
utility to use it in our services:
// 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:
// 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!
Note: Don't forget to update the related tests in userExamples.test.ts
to ensure they pass.
// 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:
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, and we just installed a Zod resolver for it.
Adding Zod validation
// 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. 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
Top comments (0)