zod icon indicating copy to clipboard operation
zod copied to clipboard

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

Open AndrewIngram opened this issue 8 months ago • 18 comments

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?

AndrewIngram avatar Apr 14 '25 10:04 AndrewIngram

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

sanadriu avatar Apr 25 '25 15:04 sanadriu

@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

shkreios avatar May 01 '25 15:05 shkreios

A real pity it requires a workaraound in v4

vladkrasn avatar May 19 '25 15:05 vladkrasn

@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

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.

LuckedCoronet avatar May 23 '25 02:05 LuckedCoronet

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.

colinhacks avatar May 23 '25 03:05 colinhacks

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.

zm-rylee avatar May 23 '25 04:05 zm-rylee

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

LuckedCoronet avatar May 23 '25 05:05 LuckedCoronet

@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

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 avatar May 23 '25 07:05 vladkrasn

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: "[email protected]"

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.

vladkrasn avatar May 23 '25 07:05 vladkrasn

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.

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

ben-eb avatar May 23 '25 08:05 ben-eb

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 avatar May 24 '25 07:05 bstein-lattice

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.

bstein-lattice avatar May 24 '25 07:05 bstein-lattice

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.

zshannon avatar May 27 '25 19:05 zshannon

@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

This works in terms of typing, but no actual validation is performed. There is no transform performed in z.custom. By returning a function, you are just returning a truthy value to signify validation success. This implementation validates the function at runtime:

import { z } from 'zod/v4';

export const $$Function = <TInput extends [z.ZodType, ...z.ZodType[]], TOutput extends z.ZodType>({
  input,
  output
}: {
  input: TInput;
  output: TOutput;
}): z.ZodType<(...args: z.output<z.ZodTuple<TInput, null>>) => z.output<TOutput>> => {
  const $Schema = z.function({
    input: z.tuple(input),
    output
  });
  return z.custom().transform((arg, ctx) => {
    if (typeof arg !== 'function') {
      ctx.addIssue('Must be function');
      return z.NEVER;
    }
    return $Schema.implement(arg as (...args: any[]) => any);
  });
};

joshunrau avatar Jun 02 '25 17:06 joshunrau

I was going to follow up today, as well. It's amazing what a fresh look after a week of doing something else can provide.

I also ran into the issue Joshua points out of doing no validation. but worse was that if you don't have the typeof arg !== 'function' check, any usage of an union object breaks because one half throws an error (fn is not a function) when it should really just fail the validation step - and move on to try any other possibilities... I like the inclusion of adding the issue to ctx.

Going back to my previous problems:

  • I discovered that I was running into the extra type issues on tuples because I'd tried to simplify my isFunction to take in a ZodType instead of a Tuple (most of my cases are just one arg) and that fails without the extra type help. (Example)
  • I also discovered the underlying issue with the type inference: the problem is that the return type that's an object doesn't get inferred correctly. Compare the inferred types of asObject and asObjectAsst in this playground. It looks like you could also clear it up by annotating the return type of isFunction, but I'm not entirely clear why this extra type assistance can't be inferred here.

Hopefully this helps others - or inspires some fixes to support this better natively.

bstein-lattice avatar Jun 02 '25 20:06 bstein-lattice

Open to being convinced otherwise.

Since ZodFunction became not-a-schema in Zod 4, there is no way to specify the one accepting a callback (another function) ¯\_(ツ)_/¯. Here is an example of classic Node's error-first callback:

Image

@colinhacks

RobinTail avatar Jun 06 '25 11:06 RobinTail

Thanks for the fix, @joshunrau. I'm trying to use it with a union (similar to @zm-rylee and @bstein-lattice ) but failing:

const s = z.number()
const schema = z.union([
  s,
  $$Function({ input: [ z.unknown() ], output: s }),
  $$Function({ input: [ z.unknown(), z.unknown() ], output: s }),
  $$Function({ input: [ z.unknown(), z.unknown(), z.unknown() ], output: s }),
])
> schema.safeParse(5)
{ success: true, data: 5 }

> result1 = schema.safeParse((a) => a)
{ success: true, data: [Function: impl] }
> result1.data(1)
1

> result2 = schema.safeParse((a, b) => a + b)
{ success: true, data: [Function: impl] }
> result2.data(1, 2)
Uncaught:
$ZodError: [
  {
    "origin": "array",
    "code": "too_big",
    "maximum": 1,
    "path": [],
    "message": "Too big: expected array to have <1 items"
  }
]

> const result3 = s.safeParse((a, b, c) => a + b + c)
{ success: true, data: [Function: impl] }
> result3.data(1, 2, 2)
Uncaught:
$ZodError: [
  {
    "origin": "array",
    "code": "too_big",
    "maximum": 1,
    "path": [],
    "message": "Too big: expected array to have <1 items"
  }
]

Is there a way to have z.custom() bail from the branch early in the union? Or did you find anything promising @bstein-lattice ?

akre54 avatar Jun 17 '25 19:06 akre54

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>>'

Adding an ugly cast for the fn?

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

peterpeterparker avatar Jun 23 '25 05:06 peterpeterparker

Thanks for the fix, @joshunrau. I'm trying to use it with a union (similar to @zm-rylee and @bstein-lattice ) but failing:

Is there a way to have z.custom() bail from the branch early in the union? Or did you find anything promising @bstein-lattice ?

Interesting. I'm not doing a union/function overload like you, so I haven't run into that. For your toy example I'd suggest just making it a variadic arg, but I realize that probably doesn't work in the real situation. The particularly interesting one to me is that if I debug through the parsing, when you hit the implement call here: https://github.com/colinhacks/zod/blob/1b0a5e589afd6ac913b0eddc3aa615224a1495c2/packages/zod/src/v4/core/function.ts#L70 It's showing func correctly as the two-arg version (a, b)=>a + b, but going through the parse call there somehow thinks it should just be one. (This is where my knowledge of Zod internals ends). And also interestingly, If it's just a union if the two-arg version, it works just fine.

Very strange. I'm sorry I don't have a better answer for you.

bstein-lattice avatar Jun 23 '25 18:06 bstein-lattice

peterpeterparker: I realize that in my previous answer I was incorrect - the parse function has the correct input function (because it's passed in) - it's that the types are checking the wrong one. Awkward. You could maybe do something by making it a discriminated union, but that's not particularly satisfying.

Separately, I now have a new problem I've run into - and it's loosely connected here, so I'll post in this issue in the hopes that someone here has an idea. I'm converting a different part of our codebase, and we have an class with functions that get validated ... but this is a recursive object. Nothing fancy, just something like

class Boxed {
   value: int
   isEqual(other: Boxed | int): boolean
}

Where this becomes a problem is in the zod recursive objects. That code works, but if I swap the array for a tuple (as I'd need to use for function parameters) it errors as a recursive definition: Playground

Has anyone else run into this?

bstein-lattice avatar Jun 27 '25 21:06 bstein-lattice

Howdy! 👋 I'm trying to migrate to v4 and feel confused about the changes regarding function(). I have had this simple definition before:

const cookieConsentSchema = z
    .object({
        functional: z.boolean().or(z.function().returns(z.boolean())),
    })

I've tried various ways similar to and including this:

const cookieConsentSchema = z
    .object({
        functional: z.union([z.boolean(), z.function({ output: z.boolean() })]),
    })

But got type errors like:

Property '_zod' is missing in type '$ZodFunction<$ZodFunctionArgs, ZodBoolean>' but required in type 'SomeType'.

Finally, I've used z.custom() like this which works but for sure isn't as nice of an API as in v3. There are more implementation done and it's rather cumbersome to reason about. Is there another better way to handle this?

const cookieConsentSchema = z
    .object({
        functional: z.boolean().or(z.custom<() => boolean>((value) => typeof value === "function" && typeof value() === "boolean")),
    })

mikaelkarlsson-se avatar Jul 01 '25 10:07 mikaelkarlsson-se

Did you try the suggested $$Function (or alternative equivalents, if you prefer different ergonomics) earlier in this issue? I would expect you could do something like z.boolean().or($$Function({ input: [], output: z.boolean() }), but you might have to play with it a little.

bstein-lattice avatar Jul 02 '25 17:07 bstein-lattice

I created a playground here: https://observablehq.com/@akre54/zod-v4-function-test for anyone curious

It seems that or works for parsing variable-length functions but union doesn't, either for the union of the $$Function helper, or within the input arg for $$Function

akre54 avatar Jul 02 '25 17:07 akre54

@bstein-lattice @akre54 Thank you for your replies! I've tried to get some ideas from the previous answers but found it rather confusing and cumbersome, quite a step back from the previous API IMO. But it might of course just be me that simply doesn't get it. 🧠 Either way, I actually ended up having to postpone the v4 upgrade so I haven't given this any more tries. I very much appreciate your feedback though and will use it when I get back on this topic again after vacation. 🏖

mikaelkarlsson-se avatar Jul 03 '25 09:07 mikaelkarlsson-se

I agree it's more confusing and cumbersome.

The goal of the helpers is to create a drop-in replacement for the previous ergonomics. But it appears to still not behave the same way at runtime, as Adam points out.

Frustrating for sure. I wish we could get an official response about it from the team.

bstein-lattice avatar Jul 03 '25 18:07 bstein-lattice

Yeah the $$Function helper works great but because the type check doesn't short circuit (it isn't evaluated until the callback is called) the parsed function is incorrectly marked as a success.

In my case, it's easy enough to do z.boolean().or($$Function({ input: [....], output: z.boolean() })), but really I'd want to do the type checking before returning the implement function, and then append an error, which would cause the next branch to be evaluated

akre54 avatar Jul 03 '25 19:07 akre54

@joshunrau

This works in terms of typing, but no actual validation is performed. There is no transform performed in z.custom. By returning a function, you are just returning a truthy value to signify validation success. This implementation validates the function at runtime:

import { z } from 'zod/v4';

export const $$Function = <TInput extends [z.ZodType, ...z.ZodType[]], TOutput extends z.ZodType>({ input, output }: { input: TInput; output: TOutput; }): z.ZodType<(...args: z.output<z.ZodTuple<TInput, null>>) => z.output<TOutput>> => { const $Schema = z.function({ input: z.tuple(input), output }); return z.custom().transform((arg, ctx) => { if (typeof arg !== 'function') { ctx.addIssue('Must be function'); return z.NEVER; } return $Schema.implement(arg as (...args: any[]) => any); }); };

I have been trying to use the $$Function but TS playground shown a type error as follows:

Type 'ZodPipe<ZodCustom<unknown, unknown>, ZodTransform<(...args: TupleInputTypeWithOptionals<TInput>) => any extends output<TOutput> ? any : output<...>, unknown>>' is not assignable to type 'ZodType<(...args: TupleOutputTypeWithOptionals<TInput>) => output<TOutput>, unknown, $ZodTypeInternals<(...args: TupleOutputTypeWithOptionals<TInput>) => output<...>, unknown>>'.
  Types of property '_output' are incompatible.
    Type '(...args: TupleInputTypeWithOptionals<TInput>) => any extends output<TOutput> ? any : output<TOutput>' is not assignable to type '(...args: TupleOutputTypeWithOptionals<TInput>) => output<TOutput>'.
      Types of parameters 'args' and 'args' are incompatible.
        Type '[...TupleOutputTypeWithOptionals<TInput>]' is not assignable to type '[...TupleInputTypeWithOptionals<TInput>]'.
          Type 'TupleOutputTypeWithOptionals<TInput>' is not assignable to type 'TupleInputTypeWithOptionals<TInput>'.
            Type '[] | TupleOutputTypeNoOptionals<TInput>' is not assignable to type 'TupleInputTypeWithOptionals<TInput>'.
              Type '[]' is not assignable to type 'TupleInputTypeWithOptionals<TInput>'.
                Type 'Tail["_zod"]["optout"] extends "optional" ? [...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?] : TupleOutputTypeNoOptionals<...>' is not assignable to type 'Tail["_zod"]["optin"] extends "optional" ? [...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?] : TupleInputTypeNoOptionals<...>'.
                  Type '[...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?] | TupleOutputTypeNoOptionals<TInput>' is not assignable to type 'Tail["_zod"]["optin"] extends "optional" ? [...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?] : TupleInputTypeNoOptionals<...>'.
                    Type '[...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?]' is not assignable to type 'Tail["_zod"]["optin"] extends "optional" ? [...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?] : TupleInputTypeNoOptionals<...>'.
                      Type '[...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?]' is not assignable to type '[...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?]'.
                        Type at position 0 in source is not compatible with type at position 0 in target.
                          Type 'TupleOutputTypeWithOptionals<Prefix>' is not assignable to type 'TupleInputTypeWithOptionals<Prefix>'.
                            Type '[] | TupleOutputTypeNoOptionals<Prefix>' is not assignable to type 'TupleInputTypeWithOptionals<Prefix>'.
                              Type '[]' is not assignable to type 'TupleInputTypeWithOptionals<Prefix>'.
                                Type 'Tail["_zod"]["optout"] extends "optional" ? [...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?] : TupleOutputTypeNoOptionals<...>' is not assignable to type 'Tail["_zod"]["optin"] extends "optional" ? [...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?] : TupleInputTypeNoOptionals<...>'.
                                  Type '[...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?] | TupleOutputTypeNoOptionals<Prefix>' is not assignable to type 'Tail["_zod"]["optin"] extends "optional" ? [...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?] : TupleInputTypeNoOptionals<...>'.
                                    Type '[...TupleOutputTypeWithOptionals<Prefix>, (output<Tail> | undefined)?]' is not assignable to type 'Tail["_zod"]["optin"] extends "optional" ? [...TupleInputTypeWithOptionals<Prefix>, (input<Tail> | undefined)?] : TupleInputTypeNoOptionals<...>'.
                                      Type 'TupleOutputTypeNoOptionals<Prefix>' is not assignable to type 'TupleInputTypeNoOptionals<Prefix>'.
                                        Type 'output<Prefix[k]>' is not assignable to type 'input<Prefix[k]>'.
                                          Type 'unknown' is not assignable to type 'input<Prefix[k]>'.

doberkofler avatar Jul 14 '25 14:07 doberkofler

My first question here would be to ask what version of Zod you're using. The playground seems not to be using Zod v4, and I think I noticed that there was some underlying Zod upgrade (I forget what version) that required some minor type changes to the $$Function helper - though the upgrade didn't solve the other issue I was exploring, so I ended up not digging in to the other type fixes. But as a quick check, rolling back to an earlier release in 3.25 would be an interesting comparison to see if it's the type change or something else in the setup.

bstein-lattice avatar Jul 14 '25 16:07 bstein-lattice

@bstein-lattice I just move the example to CodeSandbox where it is easier to change the version of the zod package.

doberkofler avatar Jul 14 '25 17:07 doberkofler

Hi @RobinTail! I'm Dosu and I’m helping the zod team.

You're right—since Zod 4, z.function() is no longer a schema and can't be embedded as an object property or union member. This is an intentional design change, documented in the changelog and migration guide. As a result, you can't directly specify a function that accepts a callback (like a Node-style error-first callback) as a property in a Zod object schema.

The only workaround is to use z.custom to manually validate the function signature at runtime. For example, to validate a function that takes a callback as an argument, you can do something like:

import { z } from "zod";

const callbackSchema = z.function({
  input: [z.union([z.instanceof(Error), z.null()]), z.string()],
  output: z.void(),
});

const nodeStyleFnSchema = z.custom((fn) => {
  if (typeof fn !== "function") return false;
  // Optionally, check arity or use callbackSchema.implement(fn) for runtime validation
  return true;
}) as z.ZodType<(cb: (err: Error | null, result: string) => void) => void>;

If you want runtime validation of the callback signature, you can use .implement:

const nodeStyleFnSchema = z.custom((fn) => {
  if (typeof fn !== "function") return false;
  try {
    // Validate the callback argument
    callbackSchema.implement((err, result) => {});
    return true;
  } catch {
    return false;
  }
}) as z.ZodType<(cb: (err: Error | null, result: string) => void) => void>;

This approach is not officially supported and comes with caveats: you lose deep type inference, and runtime validation is limited. But it's the only way to get close to v3-like behavior in Zod 4 (reference).

If you have a more complex scenario, feel free to share a minimal, runnable reproduction. Tagging @colinhacks for visibility. If this answers your question, please close the issue!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

dosubot[bot] avatar Jul 21 '25 21:07 dosubot[bot]