Designing the perfect Typescript schema validation library

There are a handful of wildly popular validation libraries in the Javascript ecosystem with thousands of stars on GitHub.

When I started my quest to find the ideal schema declaration/validation library, I assumed the hardest part would be identifying the Most Great option from a sea of excellent ones.

But as I dug deeper into the many beloved tools in the ecosystem, I was surprised that none of them could provide the features and developer experience I was looking for.

TLDR

I made a new Typescript validation library that has static type inference and the best DX this side of the Mississippi. To jump straight to README, head over to https://github.com/vriad/zod. Don't forget to smash that star button 🌟🤏😛

A pinch of context

I'm building an API for a healthcare application with Typescript. Naturally I want this mission-critical medical software to be rock-solid, so my goal is to build a fully end-to-end typesafe data layer. 

All data that passes from the client to the server AND server to client should be validated at runtime. Moreover, both the client and server should have static type definitions of all payloads, so I can catch more errors at compile-time.

I also don't hate myself, so I don't want to keep my static types and runtime type validators in sync by hand as my data model changes. Which means we need a tool that supports 🚀StAtIc TyPe InFeReNcE!!🚀 *vuvuzela sounds*

Let's look at our options!

Joi (14.3k stars)

Doesn't support static type inference. Boo.

Yup (8.6k stars)

Yup is jquense's popular, Joi-inspired validation library that was implemented first in vanilla JS, with Typescript typings added later.

Yup supports static type inference! But with a small caveat: the typings are wrong. 

For instance, the yup package treats all object properties as optional by default.

Yet the inferred type indicates that all properties are required. 😕

Yup also mis-infers the type of "required" arrays.

Finally, Yup doesn't explicitly support generic union and intersection types. This is a recurring source of frustration for the Yup community.

These may sound like nitpicks. But it's very uncool for the inferred type to not actually reflect the actual type of the validator it came from. 

Moreover, there are no plans to fix these issues in Yup's type inference because those changes would be backwards compatible. See more about thishere.

io-ts (2.7k stars)

Finally, a library that was designed for Typescript from the ground up!

Look, io-ts is excellent library. It's creator, gcanti, has done more than anyone to bring proper higher-order functional programming to Typescript with his `fp-ts` library.

But in my situation, and I think many others', io-ts prioritizes functional programming purity at the expense of developer experience. Functional purity is a valid and admirable design goal, but it makes io-ts particularly hard to integrate into an existing codebase with a more procedural or object-oriented bias. It's difficult to progressively integrate io-ts without entirely refactoring your code with a more functional flavor.

For instance, consider how to define an object schema with optional properties in io-ts:

You must define the required props with a call to t.type({...}), define your optional props with a call to t.partial({...}), and merge them with t.intersection().

Spoiler Alert! Here's the equivalent in my new library

io-ts also requires the use of gcanti's functional programming library fp-ts to parse results and handle errors. From the io-ts docs, here is the most basic example of how to run a validation:

The functional approach here is alienating for developers who aren't accustomed to it.

Again: fp-ts is fantastic resource for developers looking to keep their codebase strictly functional. But depending on fp-ts necessarily comes with a lot of intellectual overhead; a developer has to be familiar with functional programming concepts, fp-ts's nomenclature, and the Either monad to do a simple schema validation. It's alienating for devs who don't have a functional background, and it drives them to libraries like Yup, which returns incorrect types.

Introducing: Zod

So, in the tradition of many a nitpicky programmer, I decided to build my own library from scratch. What could go wrong?

The final result: https://github.com/vriad/zod.

Zod is a validation library designed for optimal developer experience. It's a Typescript-first schema declaration library with rigorous (and correct!) inferred types, incredible developer experience, and a few killer features missing from the existing libraries.

  • Uses Typescript generic inference to statically infer the types of your schemas
  • Eliminates the need to keep static types and runtime validators in sync by hand
  • Has a composable, declarative API that makes it easy to define complex types concisely

Zod was also designed with some core principles designed to make all declarations as non-magical and developer-friendly as possible:

  • Fields are required unless explicitly marked as optional (just like Typescript!)
  • Schemas are immutable; methods (i.e. .optional()) return a new instance.
  • Zod schemas operate on a Parse, don't validate! basis!

To jump straight to README, head over to https://github.com/vriad/zod. If you're feeling frisky, leave a star 🌟🤏👍

Primitives

Parsing

Type inference

Like in io-ts, you can extract the Typescript type of any schema with z.TypeOf<>.

We'll include examples of inferred types throughout the rest of the documentation.

Objects

Arrays

Plus you can explicitly define a non-empty array schema, something io-ts doesn't support.

Unions

Including Nullable and Optional types.

Zod includes a built-in z.union method for composing "OR" types.

Unions are the basis for defining nullable and optional values.

There is also a shorthand way to make a schema "optional":

Similarly, you can create nullable types like so:

You can create unions of any two schemas.

Intersections

Intersections are useful for creating "logical AND" types.

This is particularly useful for defining "schema mixins" that you can apply to multiple schemas.

Object merging

In the examples above, the return value of z.intersection is an instance of ZodIntersection, a generic class that wraps the two schemas passed in as arguments.

But if you're trying to combine two object schemas, there is a shorthand:

The benefit of using this shorthand is that the returned value is a new object schema (ZodObject), instead of a generic ZodIntersection instance. This way, you're able to fluently chain together many .merge calls:

Tuples

These differ from arrays in that they have a fixed number of elements, and each element can have a different type.

Recursive types

You can define a recursive schema in Zod, but because of a limitation of Typescript, their type can't be statically inferred. If you need a recursive Zod schema you'll need to define the type definition manually, and provide it to Zod as a "type hint".

Function schemas

Zod also lets you define "function schemas". This makes it easy to validate the inputs and outputs of a function without intermixing your validation code and "business logic".

You can create a function schema with z.function(args, returnType) which accepts these arguments.

  • args: ZodTuple The first argument is a tuple (created with z.tuple([...]) and defines the schema of the arguments to your function. If the function doesn't accept arguments, you can pass an empty tuple (z.tuple([])).
  • returnType: ZodType The second argument is the function's return type. This can be any Zod schema.
  • z.function returns a higher-order "function factory". Every "factory" has .validate() method which accepts a function as input and returns a new function. The returned function automatically validates both its inputs and return value against the schemas provided to z.function. If either is invalid, the function throws.

    This lets you confidently execute business logic in a "validated function" without worrying about invalid inputs or return types, mixing your validation and business logic, or writing duplicative types for your functions.

    Here's an example.

    This is particularly useful for defining HTTP or RPC endpoints that accept complex payloads that require validation. Moreover, you can define your endpoints once with Zod and share the code with both your client and server code to achieve end-to-end type safety.

    Onwards and upwards

    I think there's a LOT of room for improvement in how we handle the complexity of data modeling, validation, and transport as developers. Typescript, GraphQL, and Prisma are huge steps towards a future where our tooling can provide guarantees of data integrity. But there's still a long way to go.

    If you like what you see, head over to https://github.com/vriad/zod and smash that ⭐STAR⭐️ button 😛

    © Colin McDonnell 2020