profile
viewpoint

Ask questionsRFC: Transformations (e.g. casting/coercion)

⚠️ Last edit: 30 Aug

This is the proposed API for implementing data transformation functionality in Zod.

Proposed approach

No need to over complicate things. The problem to solve is how Zod should handle/support transformations from type A to type B. So I propose the creation of a new class ZodTransformer. This class is a subtype of ZodType (so you can use it like any other schema — .refine, .parse, etc).

Internally, instances of this class will have these properties

  • input: ZodType: an input schema
  • output: ZodType: an output schema
  • transformer: (arg: T['_type']) => U['_type']: a transformer function

There is only one transformer function, not a "transform chain" like in Yup (👋 @jquense). This makes it easier for Zod to statically type the function. Any sort of functional composition/piping can be done using libraries external to Zod.

You would create an instance using the ZodTransformer.create static factory method (aliased to z.transformer):

Usage

Coercing a string into a number.

const stringToNumber = z.transform(z.string(), z.number(), (data)=>parseFloat(data));
type stringToNumber = z.infer<typeof stringToNumber>; // number

stringToNumber.parse("12") // => 12 (number)

.input/.output

stringToNumber.input; // ZodString
stringToNumber.output; // ZodNumber

.transform method

Every ZodTransform instance will have a .transform method. This method lets you easily chain transforms, instead of requiring many nested calls to z.transform().

Every ZodType instance (the base class for all Zod schemas) will have a .transform method. This method lets you easily create a ZodTransform, using your current schema as the input:

const trimAndMultiply = z.string()
  .transform(z.string(), x =>x.trim())
  .transform(z.number(), x => parseFloat(x))
  .transform(z.number(), num => num * 5);

console.log(trimAndMultiply.parse(' 5 ')); // => 25

.toTransformer function

⚠️ Edit: This section is no longer relevant since the .transform method has been moved to the base ZodType class instead of only existing on ZodTransform.

As you can see above, the first method call is .transformer (which is a factory function that returns a ZodTransform). All subsequent calls are to .transform() (a chainable method on the ZodTransformer class).

To make the syntax for defining chains of transforms more consistent, I propose a toTransformer function:

const stringTransformer = z.transformerFromSchema(z.string());

// equivalent to
const stringTransformer = z.transformer(z.string(), z.string(), x => x);

With this you could rewrite trimAndMultiply like so:

const trimAndMultiply = z.toTransformer(z.string())
  .transform(z.string(), z.string(), x =>x.trim())
  .transform(z.number(), x => parseFloat(x))
  .transform(z.number(), num => num * 5)
  .refine(x => x > 20, 'Number is too small');

## .clean This section is now irrelevant and will now be implemented by overloading .transform()

⚠️ I really don't like the name "clean" for this; if you have any better ideas please make suggestions.

There will be redundancy if you are chaining together transforms that don't cast/coerce the type. For instance:

z.toTransformer(z.string())
  .transform(z.string(), val => val.trim())
  .transform(z.string(), val => val.toLowerCase())
  .transform(z.string(), val => val.slice(0,5))

I propose a .clean method that obviates the need for the redundant z.string() calls. Instead it uses this.output as both the input and output schema of the returned ZodTransform.

z.toTransformer(z.string())
  .clean(val => val.trim())
  .clean(val => val.toLowerCase())
  .clean(val => val.slice(0,5))

.default

Transformations make the setting of default values possible for the first time.

z.string().default('default_value');

// equivalent to
z.transformer(z.string().optional(), z.string(), x => x || "default_value");

Complications

Separate input and output types

There are some tricky bits here. Before now, there was no concept of "input types" and "output types" for a Zod schema. Every schema was only associated with one type.

Now, ZodTransformers have different types for their inputs and outputs. There are issues with this. Consider a simple function schema:

const myFunc = z.function()
  .args(z.number())
  .returns(z.boolean())
  .implement(num => num > 5);

myFunc(8);

This returns a simple function that checks if the input is more than 5. As you can see the call to .implement automatically infers/enforces the argument and return types (there's no need for a type signature on num).

Now what if we switch out the input (z.number()) with stringToNumber from above?

const myFunc = z
  .function()
  .args(stringToNumber)
  .returns(z.boolean())
  .implement(num => num > 5);

myFunc(8); // works
myFunc("8"); // throws

It's not really clear what should happen here. The function expects the input to be a number, but the transformer expects a string. Should myFunc("8") work?

Type guards

  1. I hadn't really considered how this will impact type guards. Like I mentioned under "Complications" in the original RFC, each schema is now associated with both an input and output type. For schemas that aren't ZodTransformers, these are the same. Type guards can only be used to verify the input type:
const stringToNumber = z.transformation(z.string(), z.number(), parseFloat);
const data = "12";

if(stringToNumber.check(data)){
  data; // still a string
}

I think perhaps typeguards aren't really compatible with any sort of coercion/transformation and it might be better just to get rid of them. @kasperpeulen

Unions

Not sure how I didn't see this issue before.

Consider a union of ZodTransformers:

const transformerUnion = z.union([
  z.transformer(z.string(), z.number(), x => parseFloat(x)),
  z.transformer(z.string(), z.number().int(), x => parseInt(x)),
])

What should happen when you do transformerUnion.parse('12.5')? Zod would need to choose which of the transformed values to return.

One solution is to have union unions return the value from the first of its child schemas that passes transformation/validation, in the order they were passed into z.union([arg1,arg2,etc]). In the example above it would return the float, and never even execute parseInt.

Another solution is just to disallow passing transformers in unions (and any other types that would cause problems) 🤷‍♂️

Design consideration

One of my design considerations was trying to keep all data mutation/transformation fully contained within ZodTransformers. This leads to a level of verbosity that may be jarring. Instead of adding a .default() method to every Zod schema, you have to "convert" your schema into a ZodTransformer first, then you can use its .default method yourself.

Try it

Most of this has already been implemented in the alpha branch, so you can play around with it. Open to any questions or concerns with this proposal. 🤙

yarn add zod@alpha

Tagging for relevance: @krzkaczor @ivosabev @jquense @chrbala @jakeginnivan @cybervaldez @tuchk4 @escobar5

vriad/zod

Answer questions chrbala

Another thought I'm having on this one is similar to #88. That is, it would be a pretty reasonable case to see an external tool parsing, validating, and creating error messages. Right now, all these things are run in zod in separate closures, which means that you have to plumb these things in multiple times, and pay the runtime cost multiple times as well.

For the sake of example, let's say Date.parse throws an error that includes a "code" property instead of returning NaN when there is an invalid input. This is similar to what npm libraries sometimes do.

const DateString = z.string().refine(s => {
  try {
    Date.parse(s);
    return true;
  } catch (e) {
    return false;
  }
}, 'need e.code here');

z.transformer(DateString, z.number(), s => Date.parse(s));

I don't want to have to run Date.parse 2-3 times to parse each input. This example is fairly small, but for some types, the runtime cost could start adding up. It would be nice to have a single closure that can encompass (1) validation (2) error code generation, and (3) transformations.

useful!

Related questions

No questions were found.
source:https://uonfu.com/
Github User Rank List