zod
zod copied to clipboard
[v4] Zod v4 infers branded keys into unbranded keys
Reproduction
In packages/zod/src/v4/classic/tests/record.test.ts, try adding this test case:
test("branded keys type inference", () => {
const recordWithBrandedNumberKeys = z.record(z.number().brand("SomeBrand"), z.number());
type recordWithBrandedNumberKeys = z.infer<typeof recordWithBrandedStringKeys>;
expectTypeOf<recordWithBrandedNumberKeys>().toEqualTypeOf<Record<number & z.$brand<"SomeBrand">, number>>();
});
Expected Behavior
We think Zod should infer this type:
type recordWithBrandedNumberKeys = {
[x: number & z.$brand<"SomeBrand">]: number;
}
Actual Behavior
But if you ask TypeScript what we infer fromrecordWithBrandedStringKeys, you'll see that we actually infer this type:
type recordWithBrandedNumberKeys = {
[x: string]: number;
}
The key has morphed from a branded number into an unbranded string! This is a regression from how Zod v3 behaved.
Screenshots
Viewing the inferred type
Viewing the test failure
Probably related to this now in the docs?
Nice find, but this is not related to that entry in the docs! If you remove .brand() from the key, then Zod has no problem creating a Record<number, number>.
As the docs say in your screenshot, "Zod allows it for the sake of parity with TypeScript's type system."
Zod4 is not properly handling branded keys - a branded anything falls through to just being a string, and this is not how the unbranded types are treated.
This is a bug in your test: you're inferring the type from recordWithBrandedStringKeys instead of recordWithBrandedNumberKeys.
The inferred key type is still just number, and does indeed drop the brand. I'll look into it.
Oh, derp. Thank you for looking into the dropped brand!
Fixed in [email protected]
Fixed by https://github.com/colinhacks/zod/commit/aff9561126e591cd9e05beda2d1c69d302bce79e. Thank you so much!