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 schemaoutput: ZodType
: an output schematransformer: (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
.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');
##
This section is now irrelevant and will now be implemented by overloading .clean
.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
- 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:
- Created 3 years ago
- Reactions:31
- Comments:38 (23 by maintainers)
Top GitHub Comments
Transformers are now in beta as part of Zod 2! https://github.com/vriad/zod/tree/v2
Wow, this is looking great!!!
Instead of
.clean()
, how about overloading.transform()
?