zod icon indicating copy to clipboard operation
zod copied to clipboard

Feature Request: Common context for schema transformations

Open OiYouYeahYou opened this issue 1 year ago • 8 comments

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.

OiYouYeahYou avatar Jun 04 '23 20:06 OiYouYeahYou

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.

indianakernick avatar Jun 06 '23 04:06 indianakernick

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). anyway... 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.

indianakernick avatar Jun 11 '23 05:06 indianakernick

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.

stale[bot] avatar Sep 09 '23 12:09 stale[bot]

Not stale

OiYouYeahYou avatar Sep 09 '23 14:09 OiYouYeahYou

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"

ferm10n avatar Nov 17 '23 20:11 ferm10n

I did some digging into the repo, and I think these are the modifications needed:

  • passing in extra context:
    • add an extraCtx prop to ParseParams type
    • add an extra prop to ParseContext type
    • modify ZodType.safeParse to assign the extra prop from its params arg
  • exposing the extra context to transformer:
    • add an extra prop to RefinementCtx type
    • modify ZodEffects._parse checkCtx to have a getter for RefinementCtx.extra

@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"
})

ferm10n avatar Nov 20 '23 20:11 ferm10n

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.

ferm10n avatar Nov 29 '23 15:11 ferm10n

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
})



john-twigg-ck avatar Feb 16 '24 05:02 john-twigg-ck