zod icon indicating copy to clipboard operation
zod copied to clipboard

When z.infer a Record with Template Literal Type as a key, it becomes a Partial Record.

Open cassus opened this issue 1 year ago • 8 comments

This issue is very similar to #2069 but instead of Branded key, the same issue is with Template Literal Type key.

When z.infer a Record with Template Literal Type as a key, it becomes a Partial Record.

With defining a Record type normally:

import { z } from "zod"

const zodKey = z.custom<`${number}`>()

type ZodKey = z.infer<typeof zodKey>
type ZodRecord = Record<ZodKey, number>
// type ZodRecord = {
//     [x: `${number}`]: number;
// }

With defining a Record type with z.infer:

import { z } from "zod"

const zodKey = z.custom<`${number}`>()
const zodRecord = z.record(zodKey, z.number())

type ZodRecord = z.infer<typeof zodRecord>
// type ZodRecord = {
//     [x: `${number}`]: number | undefined;
// }

It has undefined.


The extra Partial<...> is more explicit in this case:

import { z } from "zod"

const zodKey = z.custom<`${number}`>()
const zodRecord = z.record(zodKey, z.object({ a: z.number(), b: z.number() }))
const zodWrapper = z.object({ zodRecord })

type ZodWrapper = z.infer<typeof zodWrapper>
// type ZodWrapper = {
//     zodRecord: Partial<Record<`${number}`, {
//         a: number;
//         b: number;
//     }>>;
// }

cassus avatar May 19 '23 21:05 cassus

I have the same issue. It doesn't work in the same way as plain TS types

vadimyen avatar Jun 02 '23 20:06 vadimyen

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

stale[bot] avatar Sep 28 '23 23:09 stale[bot]

Still an issue.

rijenkii avatar Sep 29 '23 05:09 rijenkii

Recently ran into this issue as well. Was quite befuddling to uncover haha!

CaptainYarb avatar Nov 29 '23 21:11 CaptainYarb

I've offered a workaround using z.custom here: https://github.com/colinhacks/zod/issues/2623#issuecomment-1849464377

lunelson avatar Dec 11 '23 07:12 lunelson

My workaround:

z.record(branded, other).transform((x) => x as typeof x extends Partial<infer T> ? T : never);

rijenkii avatar Jan 12 '24 07:01 rijenkii

This issue actually also exists for union types.

z.record(z.literal("a").or(z.literal("b")), z.number());

resolves to

{
    a?: number | undefined;
    b?: number | undefined;
}

Rather than the expected

Record<"a"|"b", number>
// or
{
    a: number;
    b: number;
}

The workaround suggested by @rijenkii works in this case as well. Building further on his suggestion, there's actually a utility type for this, introduced in typescript 2.8. An improved workaround would look like this:

z.record(branded, other).transform((x) => x as Required<typeof x>);

The same is achievable through refine:

z.record(branded, other).refine((arg): arg is Required<typeof arg> => true)
// or
z.record(branded, other).refine(<T>(arg: T): arg is Required<T> => true)

janpaepke avatar Apr 25 '24 10:04 janpaepke

@rijenkii 's solution was the only one to work for me...

Here's a reduced example of my code:

export const SCHEMA = {
  response: z.object({
    edges: z
      .record(
        z.custom<Uppercase<string>>((val) => typeof val === 'string' && val === val.toUpperCase()),
        z.object({}),
      )
      .transform((x) => x as typeof x extends Partial<infer T> ? T : never),
  }),
};

export type SchemaResponse = z.infer<(typeof SCHEMA)['response']>;

And the correctly inferred type:

type SchemaResponse = {
    edges: Record<Uppercase<string>, {}>;
}

Without the .transform line the type is:

type SchemaResponse = {
    edges: Partial<Record<Uppercase<string>, {}>>;
}

And with the refine approach or using Required in transform the type ends up as:

type SchemaResponse = {
    edges: Required<Partial<Record<Uppercase<string>, {}>>>;
}

vitch avatar May 27 '24 14:05 vitch