Typescript schema validation
TL;DR: To validate in typescript, define an schema and infer the type from that schema
Almost all React apps I've built needs some kind of data validation. Most of the cases, the input data of the form. Sometimes the input data from the network.
There are several (lot!) of libraries to do this. In most of them, you define a schema and validates date against it.
But the usage of most of them with Typescript looks tedious: why I have to define a schema if I have a type 🙁
As you probably already know Typescript's types are not available at runtime, so it's not possible to validate or parse something against it. But the opposite is possible: define a schema and derive a type.
As you may guessed, there are several libraries to do this. I've tried two:
## zod
zod is a "TypeScript-first schema declaration and validation library". Probably the most used one. It's focused in developer comfort.
In zod, the schema is defined like this:
import \* as z from 'zod'
export const LoginInputSchema = z.object({
email: z.string().email(),
password: z.string().min(10).max(100),
});
And the derived TS type like this:
export type LoginInput = z.infer<typeof LoginInputSchema>;
You can, then, validate any input and the result will be typed:
import { LoginInput, LoginInputSchema } from "./validations";
try {
const data: LoginInput = LoginInputSchema.parse(values);
} catch (err) {
console.log("Validation error");
}
The shape of the error has a document page by itself, but long story short, it's quite easy to show validation errors in a form.
io-ts #
[io-ts] is a "runtime type system for IO decoding/encoding". As you can see by the description, the purpose is the same, but not the approach. io-ts is based on io-fs, a functional library for Typescript (functional not in the sense of lodash, more in the sense of Monads).
In io-ts we define "io types" (not actual TS types) like this:
import * as t from "io-ts";
const User = t.type({
userId: t.number,
name: t.string,
});
and the derived TS type like this:
type User = t.TypeOf<typeof User>;
Validating the input is a "little" bit more complex, so it's better to create a helper first:
import { identity } from "fp-ts/lib/function";
import { pipe } from "fp-ts/lib/function";
import { fold } from "fp-ts/lib/Either";
export function validate<A, O, I>(codec: t.Type<A, O, I>, value: I): A {
return pipe(
codec.decode(value),
fold((err) => throw new ValidationError(err), identity)
);
}
And then use it:
import validate from './validate'
try {
const user: User = validate(User, values)
} catch (err) {
...
}
As you can see, io-ts needs io-fs to work and has a much lower level API.
I'm convinced: let's use zod #
If you reach this point, some of you may wonder why I spent your time (and mine) explaining io-ts.
For one simple reason: zod is only one way (deserialization aka validation), but io-ts works in both ways (serialization and deserialization).
Or said in another words, io-ts is much more capable. Among other things you can do data coercion, that is crucial in some applications.
Summary #
If you just need validation (aka: Frontend) I recommend zod.
If you need serialization (aka: Backend) and want to explore the intricate world functional programming paradigms, I recommend io-ts.
References #
- io-ts validation helper: https://github.com/gcanti/io-ts/issues/348
- Zod compared to other libraries: https://github.com/vriad/zod#comparison
- Zod integration with react-hook-forms: https://github.com/react-hook-form/resolvers
- Types and APIs: https://www.executeprogram.com/blog/porting-to-typescript-solved-our-api-woes
- https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/