colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
32.61k stars 1.13k forks source link

`z.input<…>` of coercibles should be `any` #2519

Open twiddler opened 1 year ago

twiddler commented 1 year ago

In this code …

import { z } from 'zod'

const b = z.coerce.boolean()

type B = z.input<typeof b>

B evaluates to …

type B = boolean;

I expected B to evaluate to …

type B = any;

If this is intended behaviour, I'd be happy if you could share your reasoning. Thank you. :pray:

This is related to #2421.

zod version: v3.21.4

iway1 commented 1 year ago

I think z.input means "The type of a valid input" in the context of zod (the type before any transforms etc are made).

I believe you're correct that the input should include more types than just the type that the value is being coerced to.

But not all coercibles accept any type, for example if you do

// Errors
const parsed = z.coerce.number().parse("no");

// parsed is now the number 0
const parsed = z.coerce.number().parse(null);

So for z.coerce.number(), it seems that z.input<...> should be number | string | null since those are each input types that could parse successfully (not sure if that's all the types).

For .string() and .boolean() I think that any type should be returned from z.input since in JS everything can be converted to a string or bool

twiddler commented 1 year ago

z.input should follow the specificiation, so IMHO:

  1. Should allow: number | undefined | null | boolean | string | Record<any, any>
  2. Should not allow: symbol | bigint

(Interestingly, BigInt(Number(1)) does not throw a TypeError in Node 18. :thinking:)

akomm commented 1 year ago

I don't say it is bad do it based on spec, but I think first-place it should be based on what coerce of this library can work with. So if following the spec, then we need to make sure that coerce works based on spec, then the z.input would end up being exactly in line with it.

@iway1

For .string() and .boolean() I think that any type should be returned from z.input since in JS everything can be converted to a string or bool

Though maybe unknown would be better. It makes sense following your example of coerce.number having number | string | null, which is an union, not intersection. That means if all types are coercible to string, it must be a union (not intersection) of all types. You can think of any as an intersection of all types with some intrinsic behavior, while unknown is an union.

redbmk commented 9 months ago

It looks like this also makes it so that z.coerce.boolean().default(false) doesn't work as expected. And using null, undefined, or 0 instead gives a typescript error.

This is what I'm doing as a workaround:

z.object({
  SOME_BOOL_VALUE: z.string().default("false").transform(value => value !== "false"),
}).parse(process.env);

As a side note, since this is coming from an env, even something like SOME_BOOL_VALUE=0 or false would really be a string and get treated as true using coerce.boolean(), so maybe this is the best we can do for booleans coming from strings.

twiddler commented 9 months ago

@redbmk Unrelated to the original issue: Since you default to "false", it seems like that might be the "less risky" setting for your application, so maybe consider .transform(value => value === "true") such that your boolean is only ever true when one really intends to.

DaniFoldi commented 4 months ago

I ran into this recently, ended up using a union of primitive types and calling the appropriate constructor in .transform manually.

Posting it here as a workaround until this gets fixed in z.coerce:

import { z } from 'zod'

const zStringToNumber = z.coerce.number()

type zIn1 = z.input<typeof zStringToNumber>
//   ^?

const zStringToNumber2 = z.union([
  z.string(), // maybe NaN
  z.number(),
  z.boolean(), // 0 or 1
  z.bigint(),
  z.date(),
  z.symbol(), // throws
  z.undefined(), // NaN
  z.null() // 0
]).transform(v => Number(v))

type zIn2 = z.input<typeof zStringToNumber2>
//   ^?

TS playground

colinhacks commented 4 months ago

This is true. It was a tradeoff I made so I could ship z.coerce without a breaking change. Because ZodString has no generics, there's no way for me to change its _input type without adding one, which would have been breaking. Another option would be to introduce some kind of ZodCoerce type, but then you wouldn't be able to method calls z.coerce.string().min(5), etc.

This will be fixed in Zod 4. In the meantime you can work around it with .transform() and .pipe() as others have suggested.