zod icon indicating copy to clipboard operation
zod copied to clipboard

Proposal : make `coerce` more generic

Open qraynaud opened this issue 5 months ago • 0 comments

Hi!

I've been thinking about how to make coerce something more powerful and generic than what it is right now and I think I found an elegant solution. Not knowing zod codebase perfectly (I read a big part of it though) I might be missing things that would make this impossible. But I think it should work.

1st, add to every zod schema a schema.constructWith(inputSchema) method (that name can obviously be changed, I'm not sure it is good) that would satisfy the followings:

  • inputSchema REQUIRES a compatible OutputType type with the schema's InputType
  • That method would return a similarly typed ZodSchema except for its InputType that would be changed to the same one as the one of inputSchema
  • The returned schema would basically be the same schema but constructed with the inputSchema in its def object
  • Change the parse method to run it on inputSchema.parse(val) instead of val directly when there is one

2nd add a schema.coerce(transformFn, outputSchema) method to every zod schema that would basically just return outputSchema.constructWith(schema.transform(transformFn)).

Those changes would allow to implement existing coerce tooling more easily:

zod.coerce.number = z.string()
  .or(z.number())
  // that might not be enough to put Number only there, but it keeps things simple
  .coerce(Number, z.number());

// or:
zod.coerce.number = z.number()
  .constructWith(
    z.number()
      .or(z.string().transform(Number))
  );

// ZodNumber would need to be parameterized with its input type now
// eg: ZodNumber<Input = number> extends ZodType<number, ZodNumberDef, Input> {}

// zod.coerce.number would be defined as a ZodNumber<string | number>

If we want to keep very broad coercing tooling, it could be defined as:

zod.coerce.number = z.unknown()
  .coerce(Number, z.number()); // ZodNumber<unknown>

I think that would also allow to completely deprecate z.preprocess() that would be better served by using .coerce() or .constructWith() (it could always be simulated using z.unknown().coerce(), the only difference being that the resulting schema would be "better" typed).

Some very cool uses for this would be to create some generic schemas representing more complex things that can still be used in a very intuitive way after that:

// duration.ts
import parseDuration from "parse-duration";

export const duration = z.string()
  .min(1)
  .or(z.number())
  .coerce(
    (val: string | number) => typeof val === "string" ? parseDuration(val) : val,
    z.number()
  ) // another ZodNumber<string | number>

// example.ts
import { duration } from "./duration.js";

const requestTimeout = duration
  .int("do not support µs")
  .min(1_000, "1s minimum timeout")
 ;
 
 // ...
requestTimeout.parse("20s"); // OK => 20_000
requestTimeout.parse("200ms"); // fails with "1s minimum timeout"
requestTimeout.parse(10_000); // OK => 10_000
requestTimout.parse(10_000.25); // fails with "do not support µs"

qraynaud avatar Sep 23 '24 16:09 qraynaud