8000 v4: what is the replacement for using z.function as a schema? · Issue #4143 · colinhacks/zod · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

v4: what is the replacement for using z.function as a schema? #4143

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
AndrewIngram opened this issue Apr 14, 2025 · 13 comments
Open

v4: what is the replacement for using z.function as a schema? #4143

AndrewIngram opened this issue Apr 14, 2025 · 13 comments

Comments

@AndrewIngram
Copy link
AndrewIngram commented Apr 14, 2025

The docs say this

The result of z.function() is no longer a Zod schema. Instead, it acts as a standalone "function factory" for defining Zod-validated functions. The API has also changed; you define an input and output schema upfront, instead of using args() and .returns() methods.

We have Zod 3 code like this, which is used to give us type-safety (though presumably not runtime safety) that a function matches a shape):

export const DynamicEnvVarSchema = z.object({
  from: z.literal('dynamic'),
  evaluate: z.function(z.tuple([]), z.promise(z.string())),
});
export type DynamicEnvVar = z.output<typeof DynamicEnvVarSchema>;

What would be the most appropriate way to do this in Zod 4?

@sanadriu
Copy link
sanadriu commented Apr 25, 2025

Any update on this? Cannot even use z.function inside z.union

@shkreios
Copy link
shkreios commented May 1, 2025

@AndrewIngram @sanadriu What you can do is wrap the z.function inside z.custom, which calls implement or implementAsync.

Implementation

const functionSchema = <T extends z.core.$ZodFunction>(schema: T) =>
  z.custom<Parameters<T["implement"]>[0]>((fn) => schema.implement(fn))

const createAsyncFunctionSchema = <T extends z.core.$ZodFunction>(schema: T) =>
  z.custom<Parameters<T["implementAsync"]>[0]>((fn) => schema.implementAsync(fn))

Example

const DynamicEnvVarSchema = z.object({
  from: z.literal("dynamic"),
  evaluate: createAsyncFunctionSchema(z.function({ input: z.tuple([]), output: z.promise(z.string()) })),
})

type DynamicEnvVar = z.output<typeof DynamicEnvVarSchema>
// OutputType
type DynamicEnvVar = {
    from: "dynamic";
    evaluate: z.core.$InferInnerFunctionTypeAsync<z.ZodTuple<[], null>, z.ZodPromise<z.ZodString>>;
}

Typescript Playground link

@vladkrasn
Copy link

A real pity it requires a workaraound in v4

@LuckedCoronet
Copy link

@AndrewIngram @sanadriu What you can do is wrap the z.function inside z.custom, which calls implement or implementAsync.

Implementation

const functionSchema = <T extends z.core.$ZodFunction>(schema: T) =>
z.custom<Parameters<T["implement"]>[0]>((fn) => schema.implement(fn))

const createAsyncFunctionSchema = <T extends z.core.$ZodFunction>(schema: T) =>
z.custom<Parameters<T["implementAsync"]>[0]>((fn) => schema.implementAsync(fn))

Example

const DynamicEnvVarSchema = z.object({
from: z.literal("dynamic"),
evaluate: createAsyncFunctionSchema(z.function({ input: z.tuple([]), output: z.promise(z.string()) })),
})

type DynamicEnvVar = z.output
// OutputType
type DynamicEnvVar = {
from: "dynamic";
evaluate: z.core.$InferInnerFunctionTypeAsync<z.ZodTuple<[], null>, z.ZodPromise<z.ZodString>>;
}
Typescript Playground link

I may be dumb, can I make it allow function parameters?
Currently it results in type error if I try to pass any number of arguments in input tuple.

@colinhacks
Copy link
Owner

If people want to chime in with their uses cases, go for it. The vast majority of uses cases Ive seen in the wild are anti-patterns. Open to being convinced otherwise.

@zm-rylee
Copy link

I've been using this in v3 as a way to allow object properties to be a specific type OR a function that returns that type. Tried out v4 and I can no longer do this (at least the way I'm currently doing it).

Simple example from v3:

z.object({
    name: z.union([
        z.string(),
        z.function().args(z.any()).returns(z.string()),
    ])
})

Even in this case, it doesn't actually give an error when parsing, but it will afterwards when attempting to run the function and its arguments/return type don't match.

Trying in v4 results in an error:

z.object({
    name: z.union([
        z.string(),
        z.function({
            input: [z.any()],
            output: z.string(),
        }),
    ])
})

Type '$ZodFunction<$ZodTuple<[ZodAny], null>, ZodString>' is missing the following properties from type '$ZodType<unknown, unknown>': _zod, "~standard"

If there's a better approach for this functionality, I'd be glad to hear about it.

@LuckedCoronet
Copy link

I gave up on strictly validating functions, and ended up using z.any() and interface like this...

export const ProjectConfigSchema = z.object({
	/** Path to base configuration file to inherit from. */
	extends: z.string().optional(),
	buildConfig: BuildConfigSchema.optional(),
});

export interface ProjectConfig extends z.output<typeof ProjectConfigSchema> {
	buildConfig?: BuildConfig;
}

@vladkrasn
Copy link

@AndrewIngram @sanadriu What you can do is wrap the z.function inside z.custom, which calls implement or implementAsync.

Implementation

const functionSchema = <T extends z.core.$ZodFunction>(schema: T) =>
z.custom<Parameters<T["implement"]>[0]>((fn) => schema.implement(fn))

const createAsyncFunctionSchema = <T extends z.core.$ZodFunction>(schema: T) =>
z.custom<Parameters<T["implementAsync"]>[0]>((fn) => schema.implementAsync(fn))

Example

const DynamicEnvVarSchema = z.object({
from: z.literal("dynamic"),
evaluate: createAsyncFunctionSchema(z.function({ input: z.tuple([]), output: z.promise(z.string()) })),
})

type DynamicEnvVar = z.output
// OutputType
type DynamicEnvVar = {
from: "dynamic";
evaluate: z.core.$InferInnerFunctionTypeAsync<z.ZodTuple<[], null>, z.ZodPromise<z.ZodString>>;
}
Typescript Playground link

Starting with v3.25.21, this makes typescript complain

Argument of type 'unknown' is not assignable to parameter of type '$InferInnerFunctionType<$ZodFunctionArgs, $ZodType<unknown, unknown>>'

@vladkrasn
Copy link
vladkrasn commented May 23, 2025

If people want to chime in with their uses cases, go for it. The vast majority of uses cases Ive seen in the wild are anti-patterns. Open to being convinced otherwise.

I have a bunch of scripts, all of which run regularly on some input data. Each script has a big .js config, which is validated by Zod.

In one place, I save files, and file names are dependant upon input data of the script. I need config parameter like

fileName: (input) => `${input.param1}_Name_${input.param2}`

And I want to change this pattern willy-nilly between scripts.

In the second place, I send the file over email.

to: (input) => input.email

But when I'm in dev and I don't want to bother with providing correct email in the input data, I just change it to

to: "myemail@orgname.com"

I don't need Zod to verify inputs and outputs of the function parameters, but I at least want to have it verify type Function for the first one and type Function | string for the second one.

@ben-eb
Copy link
ben-eb commented May 23, 2025

I've been using Zod 3 function schemas as a way to help type check dynamic imports;

import { z } from 'zod';

const adapter = z.object({
  methodA: z.function().args(z.string).returns(z.unknown().promise())
  // ... many other methods ...
});

export type Adapter = z.output<typeof adapter>;

const adapterModule = z.object({
  init: z.function().returns(adapter)
});

export async function loadAdapter (): Promise<Adapter> {
  const adapter = adapterModule.parse(
    await import(`./adapters/${ADAPTER}.mjs`)
  );

  return adapter.init();
}

Each Adapter is a module that provides a generic interface over some data storage; could be a file, could be a database. They return unknown data which is then parsed by Zod in a separate file, and returned to the user.

The ADAPTER constant is a build time flag which is replaced by esbuild. Despite this being a union type it's not possible for TypeScript to pick up the definition by static analysis (this is understandable and partly why I reached for Zod to handle this use case).

With this design I can support different data storage layers, as long as they conform to the Adapter interface, without necessarily needing to bundle them all into a single file.

< 8000 p dir="auto">I guess instead of this clever code I could have a switch over Adapter:

export async function loadAdapter() {
  switch (ADAPTER) {
    case 'datasourceA': {
      const adapter = await import('./adapters/datasourceA.mjs');

      return adapter.init();
    }
    // ... repeat per each adapter ...
  }
}

This should work but it's less clean...

@bstein-lattice
Copy link

I, too, ran into this missing legacy behavior, found this thread, and then also had the problem with the tuple arguments when a non-empty.

I was (eventually) able to work around that piece - awkwardly; I believe it has to do with Typescript not figuring out the nested generics off of defaults. (lower down).

But now I've run into an extra layer of problem: the zod inference layer doesn't penetrate deep enough into the z.core.$InferInnerFunctionType so I can't do type narrowing based off of my types, and I'm utterly stumped. Is there really no way we can expose this behavior again? (That'll be a follow up post.)

The culprit for the args - at least for me and the version of Zod/Typescript I was using was that I needed to give it more assistance with the Schema types. I created a wrapper that lets me have the equivalent functionality from before:

const createFunctionSchema = <In extends z.core.$ZodFunctionArgs, Out extends z.ZodType, T extends z.core.$ZodFunction<In, Out>>(schema: T) =>
  z.custom<Parameters<T['implement']>[0]>(fn => schema.implement(fn));

export const isFunction = <In extends z.ZodTuple, Out extends z.ZodType>(
  input: In,
  output: Out,
) => {
  const schema: z.core.$ZodFunction<In, Out> = z.function({ input, output })
  return createFunctionSchema(schema);
};

I'll leave the async version as an exercise to the reader.
It does seem like in this particular playground, the previous version of createFunctionSchema would have worked OK, but for my setup this seems to get it working.

Alternatively, you could try switching to use Array instead of Tuple which similarly simplifies the types - but requires arguments to be simpler.

Playground link

@bstein-lattice
Copy link

Part2:

If people want to chime in with their uses cases, go for it. The vast majority of uses cases Ive seen in the wild are anti-patterns. Open to being convinced otherwise.

What am I doing? My code is admittedly a little overcomplicated, but we're combining two situations.

  1. We have a layer of function annotations that validate inputs, but because we prefer structured objects as parameters, we have one big bag of named fields instead of a long list of individual fields to validate. e.g. fn({field1: string, field2: number, field3: date}) instead of fn(field1: string, field2: number, field3: date)
  2. Some of our fields have legacy/backwards-compatible behavior, and so are a union of a POJO (or built-in) and a class with some encapsulation/methods.

The combination of these two things means that I need Zod inference on my method types (much like Vladimir) but also that these methods can be recognized and called safely. In v3 I was able to accomplish this without issue - including safe type inference, but with v4 I'm no longer able to do that.

Here are a couple of playgrounds illustrating the problem - the majority of the code is the same, except for the zod version + isFunction setup -> and then resulting error.

v4 playground

v3 playground

While I realize this example is somewhat contrived, it's actually not that far off (aside from reducing the complexity) from the code that I'm trying to upgrade.

@zshannon
Copy link

I'm using zod to validate the global environment my code is executing within like this:

export type ReactSwiftUIElement = {
  id: string
  type: string
  addChild(child: ReactSwiftUIElement): void
  removeChild(child: ReactSwiftUIElement): void
  updateProps(properties: Record<string, any>): void
  updateValue(value: string): void
  commitUpdate(): void
  createElement(type: string): ReactSwiftUIElement | null
}

export const ReactSwiftUIElementSchema: z.ZodType<ReactSwiftUIElement> = z.lazy(() =>
  z.object({
    id: z.string(),
    type: z.string(),
    addChild: z.function().args(ReactSwiftUIElementSchema).returns(z.void()),
    removeChild: z.function().args(ReactSwiftUIElementSchema).returns(z.void()),
    updateProps: z.function().args(z.record(z.string(), z.any())).returns(z.void()),
    updateValue: z.function().args(z.string()).returns(z.void()),
    commitUpdate: z.function().args().returns(z.void()),
    createElement: z
      .function()
      .args(z.string())
      .returns(z.lazy(() => ReactSwiftUIElementSchema).nullable()),
  })
)

export const ViewableHostSchema = z.object({
  element: ReactSwiftUIElementSchema,
})

Perhaps it's excessively conservative but I certainly wouldn't call this an "anti-pattern". Need v3-like function schemas in v4 to upgrade here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants
0