zod icon indicating copy to clipboard operation
zod copied to clipboard

[v4] Zod v4 infers branded keys into unbranded keys

Open jthemphill opened this issue 6 months ago • 4 comments

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

Hovering over `recordWithBrandedNumberKeys` to see the type Zod infers

Viewing the test failure

Hovering over the result of `expectTypeOf<>` to show the error we get when we expect the key to be branded

jthemphill avatar May 21 '25 17:05 jthemphill

Probably related to this now in the docs?

Screenshot_20250521-211833.png

Svish avatar May 21 '25 19:05 Svish

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.

jthemphill avatar May 21 '25 20:05 jthemphill

This is a bug in your test: you're inferring the type from recordWithBrandedStringKeys instead of recordWithBrandedNumberKeys.

Image

The inferred key type is still just number, and does indeed drop the brand. I'll look into it.

Image

colinhacks avatar May 22 '25 20:05 colinhacks

Oh, derp. Thank you for looking into the dropped brand!

jthemphill avatar May 23 '25 22:05 jthemphill

Fixed in [email protected]

colinhacks avatar Jun 01 '25 07:06 colinhacks

Fixed by https://github.com/colinhacks/zod/commit/aff9561126e591cd9e05beda2d1c69d302bce79e. Thank you so much!

jthemphill avatar Jun 02 '25 20:06 jthemphill