question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

RFC: Transformations (e.g. casting/coercion)

See original GitHub issue

⚠️ Update: this approach has been abandoned in Zod 3 for a more Yup-like “transform chain” approach.

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

Issue Analytics

  • State:closed
  • Created 3 years ago
  • Reactions:31
  • Comments:38 (23 by maintainers)

github_iconTop GitHub Comments

9reactions
colinhackscommented, Sep 17, 2020

Transformers are now in beta as part of Zod 2! https://github.com/vriad/zod/tree/v2

5reactions
flybayercommented, Aug 3, 2020

Wow, this is looking great!!!

Instead of .clean(), how about overloading .transform()?

z.string()
  .transform(val => val.trim())
  .transform(val => val.toLowerCase())
  .transform(val => val.slice(0,5))
Read more comments on GitHub >

github_iconTop Results From Across the Web

RFC: Transformations (e.g. casting/coercion) · Issue #100
This is the proposed API for implementing data transformation functionality in Zod. Proposed approach. No need to over complicate things. The problem to...
Read more >
Developers - RFC: Transformations (e.g. casting/coercion) -
This is the proposed API for implementing data transformation functionality in Zod. Proposed approach. No need to over complicate things. The problem to...
Read more >
0529-conversion-traits - The Rust RFC Book
This RFC proposes several new generic conversion traits. The motivation is to remove the need for ad hoc conversion traits (like FromStr ,...
Read more >
RFC 4949 Internet Security Glossary, Version 2 - IETF
Entries that are proper nouns are capitalized (e.g., "Data Encryption Algorithm"), as are other words derived from proper nouns (e.g., "Caesar cipher"). All ......
Read more >
PHP RFC: Union Types 2.0
For example, when passing a boolean to an int|string argument, both 0 and “” would be viable coercion candidates.
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found