zod icon indicating copy to clipboard operation
zod copied to clipboard

z.function arguments can't be optional

Open tigerBeA opened this issue 7 months ago • 12 comments

// Case1
function demo(value?: boolean) {
   console.log(value)
}
demo(); // work

const func = z
  .function()
  .args(z.boolean().optional())
  .implement((val) => {
    console.log(val);
  });

func(); // fail
// Case2
function demo(arg1: number, arg2?: boolean) {
   console.log(arg1, arg2)
}
demo(1); // work

const func = z
  .function()
  .args(z.number(), z.boolean().optional())
  .implement((arg1, arg2) => {
    console.log(arg1, arg2)
  });

func(1); // fail

tigerBeA avatar Nov 24 '23 08:11 tigerBeA

I tried it out in my own stackblitz

I looked it up and found that the function of optional() is to make the type value: boolean | undefined instead of value? : boolean.

Then I looked at zod, and I didn't find a way to implement z.boolean().partial(), except that the ZodObject type has a partial().

I think we should also implement a partial() for function parameter types

I don't know what the developers are thinking about this, I would love to have more discussion, thank you

summer-boythink avatar Nov 26 '23 07:11 summer-boythink

@tigerBeA Do you have any good solutions

summer-boythink avatar Nov 28 '23 02:11 summer-boythink

I have looked through the implementation of optional(), all primitive types use it, and the returned result is

primitive type | undefined. If we add partial() for primitive types, I think it will make users confused about when should use optional() or partial(). Secondly, partial() makes all properties of the object optional, what should we expect partial() for primitive types? I tried to use optional primitive values in the object

const user = z.object({
  username: z.string().optional(),
});
type test = z.infer<typeof user>;
//      ^? { username?: string | undefined;}

Internally, we loop over key-value pairs detecting which key is optional to add the question mark, but in function, we receive an array of types, so I wonder do we have any solutions to detect and then add a question mark for parameter as in object? Besides that, we must ensure the valid order of optional/required parameters in the function.

danh-luong avatar Nov 28 '23 05:11 danh-luong

@danh-luong I agree with you that it will make users confused about when should use optional() or partial() Likewise,in function,I wonder do we have any solutions to detect and then add a question mark for parameter as in object?

summer-boythink avatar Nov 28 '23 06:11 summer-boythink

So for now there is no way to pass a primitive argument as optional?

karolskolasinski avatar Nov 28 '23 06:11 karolskolasinski

So for now there is no way to pass a primitive argument as optional?

I don't currently find it in function().args()

summer-boythink avatar Nov 28 '23 06:11 summer-boythink

I don't think optional is correct here. ZodFunction.args is a thin wrapper around ZodTuple, which has a fixed length of distinctly typed arguments, then a rest type of uniform type.

The best I think you can do is something like this:

const func = z.function(
  z.tuple([]).or(z.tuple([z.boolean]))
).implement(arg=>{ /* your code here */})

rotu avatar Dec 01 '23 21:12 rotu

That won't work; z.function() wants a ZodTuple and your example gives it a ZodUnion. You'd have to do this sort of thing:

const func = z.function(z.tuple([])).or(z.function(z.tuple([z.boolean()])))

boneskull avatar Dec 02 '23 06:12 boneskull

The types don't check, but I think it works at runtime (mumble grumble). Trouble with a union of functions is that you can't call .implement on it. I also looked at z.tuple([]).rest(z.any()).refine(/*...*/) which doesn't help with type hinting but does do runtime type checking.

I think that this is an area where there's no perfect solution, and zod needs changes to truly support function overloads and/or optional arguments in a reasonable way.

rotu avatar Dec 02 '23 07:12 rotu

@rotu I agree with you that zod needs to change in this part. Are there zod maintainers to participate in the discussion?

summer-boythink avatar Dec 02 '23 08:12 summer-boythink

Having given up on implement(), I finally settled on this awful hack, which typechecks and works at runtime (but would need tweaking if using any ZodEffects):

type MyFunc = (foo: string, bar?: string): string;

const foo = z.string().describe('A foo');
const bar = z.string().optional().describe('An optional bar');
const baz = z.string().describe('Bazzed');

const innerSchema = z.union([
  z.function(z.tuple([foo, bar] as [foo: typeof foo, bar: typeof bar]), baz), 
  z.function(z.tuple([foo] as [foo: typeof foo]), baz)
]);

const myFuncSchema = z.custom<MyFunc>(value => innerSchema.safeParse(value).success);

As an added bonus, the MyFunc type is readable. OTOH you'll also need to set up a custom error map.

boneskull avatar Dec 03 '23 07:12 boneskull

I reproduced it in this TSPlayground:

import {z} from "zod";

const mySchemaWithObjectParameters = z.function()
    .args(
        z.object({
            optional: z.string().optional(),
            str: z.string(),
            nullable: z.string().nullable(), 
        }).partial(),
        z.object({
            nullable: z.string().nullable(),
            str: z.string(),
            optional: z.string().optional(),
        })
    )
    .returns(z.void());
// hover to see that the optional prop in both cases is 
type MyFuncWithObjects = z.infer<typeof mySchemaWithObjectParameters>;

const mySchemaWithStrArg = z.function()
    .args(
        z.string().optional()
    )
type MyFuncWithStr = z.infer<typeof mySchemaWithStrArg>;
// ?^ = (args_0: string | undefined, ...args_1: unknown[]) => unknown, not the expected: args_0?: string;


// see here the example of why .args(z.string().optional()) does not give the expected usage.
const fnWithQuestion = (str?: string) => str?.includes("hello");
const fnWithUndefined = (str: string | undefined) => str?.includes("world")

const hello = fnWithQuestion();
const world = fnWithUndefined();

TSPlayground

m10rten avatar Feb 27 '24 09:02 m10rten