TypeScript offers the ability to pepper one's code with type annotations, allowing the compiler to perform type checks and the language server to provide code completion. These all add up to an improved developer experience, but much of the benefits are thrown out the window once the code is shipped and running out in the wild. While TypeScript may encourage writing safer code that handles edge cases better, there are times when the need to perform runtime validations of values with non-trivial structures arises. This is typically the case when handling input from external sources, be that receiving a response from an API or from a user filling in a form.
This article will focus on form validation in React, but the same concepts can be applied to other frameworks (or lack thereof), and even to other use cases such as validating API responses or performing crude pattern matching.
Consider a form that asks a user for the following information:
- Name
- Favourite number
- Favourite colour
In TypeScript we may define a type that expresses the valid values of our form as follows:
type TForm = {
firstName: string;
email: string;
favouriteNumber: number;
favouriteColour: "blue" | "not blue";
}
For the time being, let's gloss over the fact that the type string
is not nearly restrictive enough for an email. We may be tempted to say the same thing about the first name, but names are not as simple to categorise as societal norms may suggest.
Some form libraries such as react-use-form-state
can make use of the above type to make type inferences, which is really useful. In the below example, formState.values
not only mirrors the shape of TForm
, but also infers the type of each field:
const [formState] = useFormState<TForm>();
formState.values.favouriteNumber // infers the type `number`
So what happens when the user fills in the form and clicks the submit button? Ideally, we would validate the form data first, informing the user if anything needs to be corrected. Unfortunately, TypeScript isn't going to be of much help here.
We'll need to write some validation checks, which usually end up more or less expressing the TypeScript type, except in the form of code. This kind of repetition may seem trivial when the TypeScript types match up with the built in JavaScript types; this is the case with string
and number
. But once we start dealing with more complex types, such as a string that matches a regex pattern or an array of predefined strings, the checks we need to perform begin to resemble the TypeScript types less and less.
A myriad of switch or if-else blocks seems like the way forward, but to make life easier for ourselves, we can make use of a validation library such as zod
, to reduce cognitive overhead of defining these validation checks. The zod
schema for the same form is shown below:
import * as z from "zod"
const formSchema = z.object({
firstName: z.string(),
email: z.string().email(),
favouriteNumber: z.number(),
favouriteColour: z.enum(["blue", "not blue"]),
})
This looks reasonably nice. formSchema
resembles TForm
pretty closely. zod
even provides a convenient email()
method that saves us the trouble of searching for an email regex to use. In our submit handler, we can check to see if formState.values
matches the schema we defined using zod.
const handleSubmit: React.FormEventHandler = (event) => {
event.preventDefault()
try {
formSchema.parse(formState.values)
} catch(error) {
if (error instanceof z.ZodError) {
/* map zod errors to the appropriate form fields */
return
}
}
/* submit the form to the backend */
}
To recap, we have TForm
— a type that we have defined in TypeScript — which gives us the advantage of type inference and code completion. We also have formSchema
— a "type" that we have defined using zod
— which allows us to conveniently validate the form at runtime and comes with error message built in. This is what they look like next to each other:
type TForm = {
firstName: string;
email: string;
favouriteNumber: number;
favouriteColour: "blue" | "not blue";
}
const formSchema = z.object({
firstName: z.string(),
email: z.string().email(),
favouriteNumber: z.number(),
favouriteColour: z.enum(["blue", "not blue"]),
})
The similarity is glaringly obvious. While this is a step in the right direction, especially considering that the alternative would probably be much less concise and involve littering our code with if statements, something feels off (if it doesn't, I'm gently hinting that it should). Why do we need write out the "same type" twice using different syntaxes? Wouldn't it be great if we only had to write the "type" a single time using one approach and have the other inferred from the first?
I don't know of any tool that would allow us to pass a TypeScript type and get back a zod
schema. Such a tool would need a way for us to tell it that the email
field should be validated against a regex pattern, perhaps through a magic comment. This is likely possible to implement as an extension to an IDE, but as it turns out, if we reverse our thinking and instead try and infer the TypeScript type from the zod
schema, then the problem has already been solved for us through zod
's infer
method.
const formSchema = z.object({
firstName: z.string(),
email: z.string().email(),
favouriteNumber: z.number(),
favouriteColour: z.enum(["blue", "not blue"]),
})
type TForm = z.infer<formSchema>
We now have a single source of truth that defines what our form should look like. The zod schema is useful for validating the form data, and we still get to keep all the benefits of having defined the form's type in TypeScript.
We end up with a form component that looks as follows:
import React, { FC, FormEventHandler, useEffect } from "react"
import { useFormState } from "react-use-form-state"
import * as z from "zod"
const formSchema = z.object({
firstName: z.string(),
email: z.string().email(),
favouriteNumber: z.number(),
favouriteColour: z.enum(["blue", "not blue"]),
})
type TForm = z.infer<typeof formSchema>
const Form: FC = () => {
const [formState, { number, text }] = useFormState<TForm>()
useEffect(() => {
const favouriteNumber = parseInt(formState.values.favouriteNumber)
if (!z.number().check(favouriteNumber)) return
formState.setField("favouriteNumber", favouriteNumber)
}, [formState, formState.values.favouriteNumber])
const handleErrors = (errors: { [k: string]: string[] }): void => {
const invalidFields = Object.keys(errors) as Array<keyof TForm>
invalidFields.forEach(field =>
formState.setFieldError(field, errors[field].join("; "))
)
const validFields = (Object.keys(formState.values) as Array<keyof TForm>)
.filter(field => !invalidFields.includes(field))
validFields.forEach(field =>
formState.setFieldError(field, null)
)
}
const handleSubmit: FormEventHandler = event => {
event.preventDefault()
try {
formSchema.parse(formState.values)
handleErrors({})
} catch (error) {
if (error instanceof z.ZodError) {
handleErrors(error.flatten().fieldErrors)
return
}
}
/* submit the form to the backend */
}
const validateField = (field: keyof TForm) =>
(value: unknown): string => {
const parsedResult = formSchema
.pick({ [field]: true })
.safeParse({ [field]: value })
return !parsedResult.success
? parsedResult.error.errors[0].message
: ""
}
return (
<form onSubmit={handleSubmit}>
<div>
<label>
First name
<input
{...text({
name: "firstName",
validate: validateField("firstName"),
})}
/>
</label>
<p>{formState.errors.firstName}</p>
</div>
<div>
<label>
Email
<input
{...text({
name: "email",
validate: validateField("email"),
})}
/>
</label>
<p>{formState.errors.email}</p>
</div>
<div>
<label>
Favourite number
<input
{...number({
name: "favouriteNumber",
validate: validateField("favouriteNumber"),
})}
/>
</label>
<p>{formState.errors.favouriteNumber}</p>
</div>
<div>
<label>
Favourite colour
<input
{...text({
name: "favouriteColour",
validate: validateField("favouriteColour"),
})}
/>
</label>
<p>{formState.errors.favouriteColour}</p>
</div>
<div>
<button type="submit">Submit</button>
</div>
</form>
)
}
export default Form
A few noteworthy amendments that were not previously discussed have been added to the form. The first is a useEffect
hook that converts the value of the favouriteNumber
field from a string into a number any time that value changes. This is unavoidable since even though the field has an attribute of type="number"
, which is implied by calling {...number({ // ... })}
, the browser will always return a string value. A string would automatically fail to meet the criteria defined in our schema, which forces us to first parse that string as a number (in this case we've made the choice to parse it as an integer).
useEffect(() => {
const favouriteNumber = parseInt(formState.values.favouriteNumber)
if (!z.number().check(favouriteNumber)) return
formState.setField("favouriteNumber", favouriteNumber)
}, [formState, formState.values.favouriteNumber])
Next we have a handleErrors
function that controls which errors are displayed on the screen. The error messages shown are the defaults that are shipped with zod. Although we don't make use of it here, zod provides a way to use custom error messages should we wish to go that route. This function is called in our submit handler, and conveniently allows us to clear all the errors by passing an empty object as its argument.
const handleErrors = (errors: { [k: string]: string[] }): void => {
const invalidFields = Object.keys(errors) as Array<keyof TForm>
invalidFields.forEach(field =>
formState.setFieldError(field, errors[field].join("; "))
)
const validFields = (Object.keys(formState.values) as Array<keyof TForm>)
.filter(field => !invalidFields.includes(field))
validFields.forEach(field =>
formState.setFieldError(field, null)
)
}
The formState
object returned by the useFormState
hook has its own built-in error messages. These error messages are inferred from the TypeScript type that we provide when we call useFormState<TForm>
. This is not ideal for 2 reasons. Firstly, the wording will be different from zod's error messages. Secondly, zod has stricter checks (remember the email regex?), so for example, formState.errors.email
will be empty even for an invalid email. To get around this issue we create a validateField
function that makes the form state use zod's validation checks as well as its error messages. We also use two new methods provided by zod: pick
and safeParse
. pick
allows us to select only the fields we are interested in based on an existing schema. safeParse
like parse
, compares the values passed to it against the schema. The difference being that safeParse
does not throw when validation errors occur.
const validateField = (field: keyof TForm) =>
(value: unknown): string => {
const parsedResult = formSchema
.pick({ [field]: true })
.safeParse({ [field]: value })
return !parsedResult.success
? parsedResult.error.errors[0].message
: ""
}
useFormState
also returns some input functions that apply the HTML type
and name
attributes. These input functions also accept a validate function, which overrides the default validations performed by inferring the types from TypeScript and returns the error message if any. This is where we'll plug in our validateField
function to ensure that we are only using the validation rules and error messages provided by zod.
<input
{...text({
name: "email",
validate: validateField("email"),
})}
/>
The above snippet is roughly equivalent to the following:
<input
name="email"
onChange={(event): void => {
formState.setFieldError(
"email",
validateField("email")(event.currentTarget.value),
)
}}
type="text"
/>
Last but not least, we modify the JSX so that error messages are displayed next to their corresponding field.
<label>
Email
<input
{...text({
name: "email",
validate: validateField("email"),
})}
/>
</label>
<p>{formState.errors.email}</p>
Here is a running example of the form described in this post.