isolatedDeclarations and Zod

One strategy for using TypeScript’s isolatedDeclarations flag with inference-driven libraries like Zod.

Assumed audience: TypeScript developers who already understand what the isolatedDeclarations compiler option is and why you would be interested in it, and why it doesn’t work with Zod.

I’ve been thinking for a while at work about how our code base, which is a heavy Zod user, could flip on TypeScript’s isolatedDeclarations flag to improve overall type checking performance. This is currently not possible to do directly from the schemas Zod generates, because Zod (like a lot of other libraries in JS/TS ecosystem) starts with runtime code and produces types from it:

import * as z from 'zod';

export const UserSchema = z.object({
  name: z.string().optional(),
  age: z.number().nonnegative(),
});

export type User = z.infer<typeof UserSchema>;

Because the types are inferred from the runtime code, isolatedDeclarations does not work here: for TypeScript to understand the public API of the module, including to emit declarations, it must walk the full schema declaration to determine the resulting type: there is no other source of truth for it.

There is a deep tradeoff between this design and one like Serdes, which starts with the data type and creates the deserializer/parser from it:

use serde::Deserialize;

#[derive(Deserialize)]
struct User {
    name: Option<String>,
    age: u8,
}

The tradeoff is this: with Zod, you have a very expressive DSL for writing the deserialization/parsing logic, but have a harder time seeing what the resulting type is. It can also become arbitrarily expensive for the compiler to walk that DSL. With the Serde design, the type itself is right there and is a place you can do concrete design work, but at the cost of having to generate (potentially a lot of) code via Rust’s macro system. That generated code can be arbitrarily complex to generate and expensive to type check, too!

(I am still developing my thoughts on that tradeoff… but the rest of this post is one step in a direction I think is helpful.)

Working on true-myth-zod and thinking a bunch about separating domain model” (i.e., the types that we think of as the source of truth for our business logic”) from our API and persistence types gave me the idea simply to invert the way the declaration works:

import * as z from 'zod';

export interface User {
  name?: string | undefined;
  age: number;
}

export const userSchema: z.ZodType<User> = z.object({
  name: z.string().optional(),
  age: z.number().nonnegative(),
});

You can see that this works in the TypeScript playground I just whipped up with that exact code and isolatedDeclarations: true.

This has an additional benefit for authoring and maintaining this code. We get to write the type we want as the target for, rather than the output of, the Zod schema. This makes it much easier for us to be sure that the schema generates the type that we want! This also means that we can evolve the type first when we want to make a change and then update our schema to match. As good as Zod’s DSL is, I think it is much easier for most people to write a type that expresses what they want correctly and then check that a schema conforms to it than to make sure that their authored schema has the type they want by exploring it via hover in their text editor or IDE.

You can also extend this pretty easily with tools like neverthrow or True Myth. Here’s a nice little utility for writing a Zod parser that produces a True Myth Result, and exporting the resulting types:

import type { Result } from 'true-myth';
import { parserFor } from 'true-myth-zod';
import * as z from 'zod';

// This uses `z.core.$ZodError` so that it can be used with either the
// full Zod library or Zod Mini.
type ParseResult<T> = Result<T, z.core.$ZodError>;

type ParserFor<T> = (data: unknown) => ParseResult<T>;

export interface User {
  name?: string | undefined;
  age: number;
}

export const parseUser: ParserFor<User> = parserFor(z.object({
  name: z.string().optional(),
  age: z.number().nonnegative(),
}));

This is also fully compatible with isolatedDeclarations (playground). I will probably ship this directly in true-myth-zod shortly, and I will definitely be using this pattern for my own parsers using Zod going forward!