zod
zod copied to clipboard
Feature Request: Common context for schema transformations
TL;DR
I would like to have a locals object for transformers so it's easier to deal with deeply nested objects. For example:
const mediaSchema = z
.object({
path: z.string(),
})
.transform((media, locals) => ({ ...media, path: locals.url + media.path }))
const locals = {
url: 'https://example.com/',
}
mainSchema.parse(obj, locals)
Problem
I have a media schema which comes with just the path, as such:
const mediaSchema = z.object({
path: z.string(),
})
{
"path": "/media/some-image.jpg"
}
I'd like to dynamically transform the path into a full URL, so each parse could have a different domain, eg: https://example.com/media/some-image.jpg
and https://different-example.com/media/some-image.jpg
,
But, currently it is served in multiple places in a larger schema. For example:
const mainSchema = z.object({
deeply: z.object({
nested: z.object({
media: mediaSchema,
}),
}),
more: z.object({
nested: z
.object({
media: mediaSchema.array(),
})
.array(),
}),
})
There are some cumbersome ways to solve this, which all step around using Zod, instead of using Zod out the box. But the best way I think this could be done is to use some sort of common context passed down to transformation functions.
It's a little bit hacky, but you can use the path to pass data from the parse function down to the transformations (and refinements).
const mediaSchema = z
.object({
path: z.string(),
})
.transform((media, ctx) => ({
...media,
path: (ctx.path[0] as any).url + media.path,
}));
const locals = {
url: 'https://example.com/',
};
schema.parse(obj, { path: [locals as any] });
You'll need to remove that extra path component from errors though. It would be nice if there was a more direct way of doing this.
I'm using the technique I described in my previous comment. It's a bit gross. I don't like the as any
(although a built-in way is probably going to use any
since otherwise you'd need to add generic parameter to parse
and propagate that all the way through). any
way... I'd thought I talk about my use case and why I think this feature would be useful.
I have a collection of entities that reference each other. They use file paths to reference each other, but in the output they use IDs. The IDs are declared in the input. To make this work, I need to do two passes. I first need to go through all the objects and parse them to extract their IDs and their references. During that pass, I can build up a mapping from file path to ID. Then with a second pass, I can apply this mapping and replace the file paths with the IDs.
To make this as generic as possible, I'm annotating references with a transform
. This transform will check the type of path[0]
(see previous comment) so that it can add references to a data structure during the first pass, and query those references from the data structure during the second pass. This means that I don't need to have external code that knows the shape of my objects and will have to change whenever the schema changes. Right there in my schema, I can say "this thing is a reference" and that's it.
For the implementation, chucking an any
property in there that gets passed down to where it needs to go is obviously going to be the easiest. However, using a generic parameter for this data might be interesting. It would mean that you could have the output type dependent on the context type. That could be kind of cool.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
Not stale
My team and I also have an interest in something like this. We have a config object where a user can define conditional logic and use "variables". When a user wants to rename a variable, we wanted to automatically update all other places that reference that variable name.
This is our current planned approach:
import { z } from 'zod';
let renameCfgs: {
from: string;
to: string;
}[] = [];
export function setRenameCfg(cfg: typeof renameCfgs) {
renameCfgs = cfg;
}
export const varNameSchema = z
.string()
.min(1)
.transform((val) => {
for (const renameCfg of renameCfgs) {
if (val === renameCfg.from) {
return renameCfg.to;
}
}
return val;
});
demo: https://stackblitz.com/edit/stackblitz-starters-vjmn8x
This example assumes that only one parse happens at a time. I don't like how there's state in renameCfgs
alongside of the parsing, and I'd rather it take a more functional approach, which is something we could do if we had a "parse/transform context"
I did some digging into the repo, and I think these are the modifications needed:
- passing in extra context:
- add an
extraCtx
prop toParseParams
type - add an
extra
prop toParseContext
type - modify
ZodType.safeParse
to assign theextra
prop from itsparams
arg
- add an
- exposing the extra context to transformer:
- add an
extra
prop toRefinementCtx
type - modify
ZodEffects._parse
checkCtx
to have a getter forRefinementCtx.extra
- add an
@OiYouYeahYou @indianakernick and anyone else who wants this ability without waiting on zod to change, if you don't mind passing the context in with the original value to be parsed, I think we can get this to work by extending the ZodEffects
class like this:
demo: https://stackblitz.com/edit/stackblitz-starters-rkxa2c?file=index.ts
import { z } from 'zod';
import { ZodEffectsWithParseCtx } from './ZodEffectsWithParseCtx';
export const mySchema = ZodEffectsWithParseCtx.create(baseSchema, {
type: 'transform',
transform(val, ctx) {
let rootParseCtx = ctx.parseCtx;
while (rootParseCtx.parent !== null) {
rootParseCtx = rootParseCtx.parent;
}
const extraStuff: ExtraStuff | null = rootParseCtx.data?._extraStuff || null;
if (extraStuff) {
// do something with extraStuff
}
return val;
},
});
mySchema.parse({
...myObj,
_extraStuff: "parse context"
})
UPDATE: it seems like the ZodUnion clears out the context, so these solutions don't work :( I have an idea for how to add this in formally, if it pans out I'll link a PR
UPDATE UPDATE: this was my fork, but this PR beat me to the punch.
https://github.com/colinhacks/zod/pull/3023
@colinhacks Can this PR please merge. Zod is really in a bad position without this feature.
Without it, you have to tunnel the context through all your schema fragments, likely wrapping them in a closure. This has large performance impact for large schemas.
Example:
type MyContext = Record<string, boolean>
const teacher = (ctxt: MyContext) => z.object({
__typename: z.literal('Teacher'),
id: z.string(),
classroom: z.string(),
status: z.string(),
}).transform((data) => {
if (ctxt['budgetCuts']) {
data.status = 'fired'
}
return data
})
const principal = (ctxt: MyContext) => z.object({
__typename: z.literal('Principal'),
id: z.string(),
staff: z.array(teacher(ctxt)),
})
principal({ "budgetCuts": false }).parse({
// ... buncha data
})