colinhacks / zod

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

Type changes in 3.23 breaks some code #3434

Closed emanuel-lundman closed 4 months ago

emanuel-lundman commented 4 months ago

We have a code base, where we've used branded strings for a long time. (before ZodBranded was added). And to make that work, I believe the recommended way at the time was for them to be cast as ZodSchema<BrandedStringType>

We also have a function requiring a ZodObject with a defined Input and Output. (we need the .shape property) After upgrading to 3.23, what's the best workaround for this, here's an example where the type checking is getting angry:

Exemple:

type BrandedString = Branded<string, "BrandedString">;
const aBrandedString = z.string() as unknown as z.ZodSchema<BrandedString>;

const helloSchema = z.object({
    name: aBrandedString,
});
type Hello = z.infer<typeof helloSchema>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const func = (hello: z.ZodObject<any, any, any, Hello, Partial<Hello>>) => {
    // ...
}

const funcResult = func(helloSchema); // this type errors out

The call to func doesn't like the helloSchema since 3.23 because it now thinks name of hello could be { name?: unknown }

Argument of type 'ZodObject<{ name: ZodType<BrandedString, ZodTypeDef, unknown>; }, "strip", ZodTypeAny, { name: string & _BrandedTagged<"BrandedString">; }, { ...; }>' is not assignable to parameter of type 'ZodObject<any, any, any, { name: string & _BrandedTagged<"BrandedString">; }, Partial<{ name: string & _BrandedTagged<"BrandedString">; }>>'.
   Type '{ name?: unknown; }' is not assignable to type 'Partial<{ name: string & _BrandedTagged<"BrandedString">; }>'.
     Types of property 'name' are incompatible.
       Type 'unknown' is not assignable to type '(string & _BrandedTagged<"BrandedString">) | undefined'.ts(2345)

We could get around this by doing something like (to mimic zodtypes/zodschema old behavior (Input = Output):

const aBrandedString = z.string() as unknown as z.ZodSchema<BrandedString, ZodTypeDef, BrandedString>;

or maybe for convenience:

type ZodSchemaOf<T> = ZodSchema<T, ZodTypeDef, T>
const aBrandedString = z.string() as unknown as ZodSchemaOf<BrandedString>;

So my question is, what is the recommended way of getting around and solving these new type issues when upgrading to 3.23? (I should perhaps add that the real func is generic, and the Hello type is passed as a generic type). Is it reverting back to setting Input = Output manually like it was before the best way, or is there a better way to solve this for the future? Maybe there is a better way to accept a type safe ZodObject as a parameter? We could of course switch to AnyZodObject but then we would loose the type safety of the func hello parameter in the example. 🤔

colinhacks commented 4 months ago

Sorry for the inconvenience with this. The short answer is that this has been solved in 3.23.2 by setting the default Output and Input back to any from unknown (as in 3.23.0). This behavior is more intuitive in more cases.

Were that not the case, the real solution here would be to extract both HelloOutput and HelloInput like so (if I'm understanding correctly). That is:

type Branded<T, Brand> = T & { __brand: Brand };
type BrandedString = Branded<string, "BrandedString">;
const aBrandedString = z.string() as unknown as z.ZodSchema<BrandedString>;

const helloSchema = z.object({
  name: aBrandedString,
});
type HelloOutput = z.output<typeof helloSchema>;
type HelloInput = z.input<typeof helloSchema>;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const func = (hello: z.ZodObject<any, any, any, HelloOutput, HelloInput>) => {
  // ...
};

const funcResult = func(helloSchema); // this type works now
emanuel-lundman commented 4 months ago

I can see you closed the issue. Just jotting down for history.

I had tested 3.23, but that didn't do it. However, the new 3.24 did. (I can see that it reverted back to Input = Output).

The real solution provided in code I don't think would help us because in our real code the func is a generic (I mentioned it briefly but not in the simplified example code). All we know at the time of writing is the generic type, we don't know the schema. So if I'm not forgetting anything obvious (bit tired after a busy week) in our case, the solution wouldn't be the desired route we would like to take, would mean adding a lot of code.

Anyway, thank you for your response! And since the reversion in 3.24 it's all working again.