Logo
Published on

Form Validation with Zod

Authors

Zod schema validation

Getting started with zod can be a bit overwhelming, in this article, we'd go over form validation with zod, how to simplify the process and make it easier. We'd be using React, FormData, html form and zod for our validation. If you would like to move on with building typescript generic components, check this article out.

What is zod?

According to the official website for Zod, Zod is a TypeScript-first schema declaration and validation library. The term "schema" refers to any data type, from a simple string to a complex nested object. In simple terms, Zod helps you validate and parse your data to satisfy a certain shape. To get started with zod you'd need a schema, and data. Let's look at defining a basic zod schema

import z from 'zod'
// you can also destructure the items import {string, object, z} from 'zod'

const SimpleSchema = z.object({
  name: z.string(),
  age: z.number(),
})

After defining our schema, we use it to validate the shape of any data passed into it. If there are errors, it will throw a ZodError. For a more comprehensive guide on Zod errors and the ZodError type, I recommend reading the documentation.

To safely use our schema, it's best to wrap it in a try-catch block:

try {
  const res = SimpleSchema.parse(data)
  // do something with the res
} catch (error) {
  console.error(error)
  // apply better error handling
}

With that out of the way, let's use Zod to validate a signup form. We define a signup form with basic fields below:

const fields = [
  { name: 'fullName', label: 'Full Name', placeholder: 'Enter Full name' },
  { name: 'username', placeholder: 'Enter Username' },
  /** We can ue zod to validate that our value is of type email */
  { name: 'email', placeholder: 'Enter email', type: 'email' },
  { name: 'password', placeholder: 'Enter password', type: 'password' },
]

const SignUpForm = () => (
  <form className="mx-auto max-w-md space-y-5 py-10">
    <h2 className="text-2xl font-bold">Complete the form to sign up</h2>
    <div className="space-y-4">
      {fields.map(({ name, placeholder, type, label }) => (
        <div className="grid" key={name}>
          <label htmlFor={name} className="text-sm capitalize">
            {label ?? name}
          </label>
          <input
            name={name}
            placeholder={placeholder}
            type={type ?? 'text'}
            className="placeholde r:capitalize border p-3"
            id={name}
          />
        </div>
      ))}
    </div>
  </form>
)

After creating our form, we can define the schema for our form.

const invalid_type_error = 'Invalid type provided for this field'
const required_error = 'This field cannot be blank'

export const SignUpSchema = z.object({
  fullName: z.string({ invalid_type_error, required_error }).min(1, 'Value is too short'),
  username: z.string({ invalid_type_error, required_error }).min(1, 'Value is too short'),
  email: z
    .string({ invalid_type_error, required_error })
    .email('Please provide a valid email')
    .min(1, 'Value is too short'),
  password: z.string({ invalid_type_error, required_error }).min(6, 'Password is too short'),
})

A common issue with zod is that an empty string is the valid as type string, the fix for this would be specifying a minimum length of 1 and by default all values are required

const handleSubmit = (e: React.ChangeEvent<HTMLFormElement>) => {
  e.preventDefault()

  //  conversion of the input from FormData to an object
  const data = Object.fromEntries(new FormData(e.currentTarget))

  try {
    const res = SignUpSchema.parse(data)
    console.log(res)
  } catch (error) {
    console.log(error)
  }
}

Error Handling

Before we proceed, it's important to note that the following steps work well with one-level data or objects without nesting. This approach is suitable for validating form values since native form values are typically not nested. Now let's dive into the error handling process.

To begin, we define a function called handleOneLevelZodError, which handles error messages generated by Zod validation. It takes a ZodError<unknown> object as an argument.

//  @argument ErrorType : if you need to assert your error type, just pass it as a generics via asserting with the as keyword
const handleOneLevelZodError = ({ issues }: ZodError<unknown>) => {
const formData: Record<string, string> = {};

// - line of code should be true if the schema is not an object
// - This line is completely optional
  if (issues.length === 1 && issues[0].path.length < 1)
  return issues[0].message;

issues.forEach(({ path, message }) => {
formData[path.join('-')] = message;
});

return formData;
};

export default handleOneLevelZodError

The handleOneLevelZodError function initializes an empty object called errorMessages. It then iterates through the issues array of the ZodError object, extracting the path and message for each issue. The path is joined with a hyphen (-) and used as the key in the errorMessages object, with the corresponding error message as the value.

If the ZodError contains only one issue and the path length is less than 1, indicating that the schema is not an object, the function returns the error message directly. Please note that this line is optional and can be removed if it doesn't apply to your specific use case.

Finally, the function returns the errorMessages object containing the error messages. You can utilize this object to display the error messages to the user or handle them in any other appropriate manner within your application.

By importing and using the handleOneLevelZodError function, you'll be able to handle error messages generated during Zod validation effectively.

handleOneLevelZodError allows us to validate schemas of type zod object or zod primitives . The next step is to define a reusable function that extracts the repetitive try-catch block into a well-typed function

type ZObjectType = ZodType<Record<string | number, unknown>>

type ZodParams<T extends ZObjectType> = {
  onSuccess(data: T['_output']): void
  onError(error: Partial<Record<keyof T['_output'], string>>): void
  data: Record<string, unknown>
  schema: T
}

export type ValidationError<T extends ZObjectType> = Partial<Record<keyof T['_output'], string>>

export const handleZodValidation = <T extends ZObjectType>(params: ZodParams<T>) => {
  const { data, onError, onSuccess, schema } = params

  try {
    const res = schema.parse(data)
    onSuccess(res)
  } catch (error) {
    if (error instanceof ZodError) {
      const formattedErr = handleOneLevelZodError(error)
      onError(formattedErr as Record<keyof T['_output'], string>)
    } else {
      throw new Error(String(error))
    }
  }
}

The code defines a type ZObjectType, which represents a Zod schema type narrowed to objects, so we can use the keys for generating our error messages. Additionally, the ZodParams type defines the expected parameters for the handleZodValidation function, including onSuccess and onError callbacks, input data, and the Zod schema to validate against.

The ValidationError type is defined to represent partial error messages for the validated schema. It uses keyof T["_output"] to ensure the error object keys align with the expected output of the Zod schema.

The handleZodValidation function takes in the params object and attempts to parse the input data using the provided schema. If parsing succeeds, the onSuccess callback is invoked with the parsed result. If a ZodError is thrown during parsing, the handleOneLevelZodError function is called to format the error messages. The onError callback is then invoked with the formatted error object. If any other type of error occurs, it is thrown to be caught and handled elsewhere.

By utilizing the handleZodValidation function, you can simplify your validation code and handle success and error cases more effectively.

const [errors, setErrors] = useState<ValidationError<typeof SignUpSchema>>({})
const handleSubmit = (e: React.ChangeEvent<HTMLFormElement>) => {
  e.preventDefault()

  /** conversion of the input from FormData to an object */
  const data = Object.fromEntries(new FormData(e.currentTarget))

  handleZodValidation({
    onError: setErrors,
    data: data,
    onSuccess: (res) => {
      /**
       * res is of type:
       * { fullName: string; username: string; email: string; password: string; }
       */
      console.log(res)
    },
    schema: SignUpSchema,
  })
}

In conclusion, Zod proves to be a powerful tool for form validation, especially when combined with TypeScript. By leveraging Zod's schema declaration and validation capabilities, developers can ensure that the data submitted through forms adheres to the desired structure and meets specific validation rules.

Thank you for making it all the way here, if you are interested in creating a typescript generic component, I've got something for you here.