5

I understand that the point of zod is to parse untrusted input data and assert that it's of a type that matches your schema.

But usually that data is coming in via web APIs that guarantee at least its top-level shape, like string or object.

It seems like it would make sense for zod to do this top-level type checking on parse(), if only to prevent silly mistakes like typos. But, seemingly, it doesn't.

As a simplified example to illustrate

const schema = z.string().email();
schema.parse(1); // no type error here - why?

It seems like parse(1) should have a compile time type error, because we know it's impossible that the literal number 1 would validate correctly at runtime. We can't do that with some random string input - the runtime parsing to ensure it's a valid email is required - but a number here seems like obvious programmer error and should not even compile.

A more practical example, which led me to ask this question

async function validateRequest(request: Request) {
  const someSchema = z.object({ ... })
  return someSchema.parse(request.json()) // didn't await request.json() so won't work
}

A silly mistake like omitting the await above, seems like it should be easy to catch by someSchema.parse() checking that I pass it an object, not a Promise<object>.

So, is there a way to enable this top-level type checking with zod?

Or is this behaviour intentional for some reason I haven't understood about zod's design?

davnicwil
  • 28,487
  • 16
  • 107
  • 123

1 Answers1

0

I tried to dig around for a way to do this in Zod but couldn't find anything convincing. I suspect that use case may not be supported out of the box. One suggestion if you want to continue doing your parsing with Zod would be to create a wrapper function with stricter types that internally calls your Zod schema.

// I went for a record type because Promise<any> is assignable to object
function parseResponse(input: Record<string, unknown>) {
  const someSchema = z.object({ ... });
  return someSchema.parse(input)
}
async function validateRequest(request: Request) {
  return parseResponse(request.json()) // This will throw because now it sees the promise
}

Edit:

In fact you could write this sort of helper in general like this:

import { z } from "zod";
const schema = z.object({
  field: z.string(),
})

function restrict<T, Output, Def extends z.ZodTypeDef, Input = Output>(schema: z.ZodType<Output, Def, Input>) {
  return (t: T) => schema.parse(t);
}
type InferTypes<Z> = Z extends z.ZodType<infer Output, infer Defs, infer Input> ? [Output, Defs, Input] : [never, never, never];
type InferOutput<Z> = InferTypes<Z>[0];
type InferDefs<Z> = InferTypes<Z>[1];
type InferInput<Z> = InferTypes<Z>[2];

const validate = restrict<
  Record<string, unknown>,
  InferOutput<typeof schema>,
  InferDefs<typeof schema>,
  InferInput<typeof schema>
>(schema);

validate({}); // This typechecks because it's possible
async function foo(req: Request) {
  return validate(req.json()); // This has a type error.
}

With the only real downside being the stack of type inferring you need to do to get the generics working.

Alternative Library

If you're open to alternative libraries, this is something supported in a relatively first class way in io-ts. You can explicitly state the input and output types of your schemas, they just begin as unknown by default. The tradeoff is with developer experience. io-ts uses a functional paradigm that takes a lot of time upfront to learn.

Souperman
  • 5,057
  • 1
  • 14
  • 39