colinhacks / zod

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

Branded record keys break generics when used in an object with other branded types #3799

Open greg-sims opened 4 weeks ago

greg-sims commented 4 weeks ago

Versions

Zod: 3.23.8 Typescript: 5.6.3

Observations

import { z } from 'zod';

function testFn<T>(zodType: z.ZodType<T>) {
  return zodType;
}

const branded_1 = z.string().brand('type1');
const branded_2 = z.string().brand('type2');
const bad_record = z.record(branded_1, z.string());

const good_1 = z.object({
  branded_1,
});
testFn(good_1); // No type errors, a branded_1 in an object is fine

const good_2 = z.object({
  branded_2,
});
testFn(good_2); // No type errors, branded_2 in an object is fine

const good_3 = z.object({
  branded_1,
  branded_2,
  record_1: z.record(branded_1, z.any()),
  record_2: z.record(branded_1, z.unknown()),
  record_3: z.record(branded_1, z.undefined()),
});
testFn(good_3); // No type errors, we can have branded records where the value is potentially undefined

const good_4 = z.object({
  bad_record,
});
testFn(good_4); // No type errors, bad_record is not bad by itself

const good_5 = z.object({
  x: z.number(),
  bad_record,
});
testFn(good_5); // No type errors, bad_record is not bad with some other non-branded types

const bad_1 = z.object({
  branded_1,
  bad_record,
});
testFn(bad_1); // Type error!, bad_record cannot be put with another branded type here

const bad_2 = z.object({
  branded_2,
  bad_record,
});
testFn(bad_2); // Type error! it doesn't matter if it's mixed with the branded type it uses, any other branded type will cause the issue

const bad_3 = z.object({
  a: z.object({
    branded_2,
  }),
  b: z.object({
    bad_record,
  }),
});
testFn(bad_3); // Type error! Issue still occurs when separately nested

Errors are:

mre.ts(45,8): error TS2345: Argument of type 'ZodObject<{ branded_1: ZodBranded<ZodString, "type1">; bad_record: ZodRecord<ZodBranded<ZodString, "type1">, ZodString>; }, "strip", ZodTypeAny, { ...; }, { ...; }>' is not assignable to parameter of type 'ZodType<{ branded_1: string & BRAND<"type1">; bad_record: Partial<Record<string & BRAND<"type1">, string>>; }, ZodTypeDef, { branded_1: string & BRAND<...>; bad_record: Partial<...>; }>'.
  The types of '_input.branded_1' are incompatible between these types.
    Type 'string' is not assignable to type 'string & BRAND<"type1">'.
      Type 'string' is not assignable to type 'BRAND<"type1">'.
mre.ts(51,8): error TS2345: Argument of type 'ZodObject<{ branded_2: ZodBranded<ZodString, "type2">; bad_record: ZodRecord<ZodBranded<ZodString, "type1">, ZodString>; }, "strip", ZodTypeAny, { ...; }, { ...; }>' is not assignable to parameter of type 'ZodType<{ branded_2: string & BRAND<"type2">; bad_record: Partial<Record<string & BRAND<"type1">, string>>; }, ZodTypeDef, { branded_2: string & BRAND<...>; bad_record: Partial<...>; }>'.
  The types of '_input.branded_2' are incompatible between these types.
    Type 'string' is not assignable to type 'string & BRAND<"type2">'.
      Type 'string' is not assignable to type 'BRAND<"type2">'.
mre.ts(61,8): error TS2345: Argument of type 'ZodObject<{ a: ZodObject<{ branded_2: ZodBranded<ZodString, "type2">; }, "strip", ZodTypeAny, { branded_2: string & BRAND<"type2">; }, { branded_2: string; }>; b: ZodObject<...>; }, "strip", ZodTypeAny, { ...; }, { ...; }>' is not assignable to parameter of type 'ZodType<{ a: { branded_2: string & BRAND<"type2">; }; b: { bad_record: Partial<Record<string & BRAND<"type1">, string>>; }; }, ZodTypeDef, { ...; }>'.
  The types of '_input.a.branded_2' are incompatible between these types.
    Type 'string' is not assignable to type 'string & BRAND<"type2">'.

Expected

testFn should take any instance of ZodType, which ZodObject extends, without error.