colinhacks / zod

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

`z.record` with a key of a union or enum schema results in a partial record #2623

Open hayata-suenaga opened 1 year ago

hayata-suenaga commented 1 year ago

If a union or enum schema is passed to z.record as a key type, the resulting schema has all properties as optional for both the parsing logic and the inferred TypeScript type.

I propose that we make the behavior of z.record similar to that of TypeScript's. If you pass an union or enum type to Record in TypeScript, the resulting type has all properties required.

I understand changing the existing behavior of z.schema would be a breaking change. For now, how about introducing a new zod type z.strictRecord where all properties are required?

I apologize if this has been considered before. Please let me know if there are specific reasons the behavior ofz.schema differs from TypeScript's πŸ™‡

I also found related issues and listed them below for reference.

The following example code illustrates the current behavior of z.record.

import { z } from 'zod';

const exampleEnumSchema = z.enum(['foo', 'bar']);
const exampleRecordSchema = z.record(exampleEnumSchema, z.string());

type ExampleRecord = z.infer<typeof exampleRecordSchema>;
// {
//     foo?: string | undefined;
//     bar?: string | undefined;
// }

exampleRecordSchema.parse({foo: 'foo'}); // doesn't error

The following example code illustrates the behavior of TypeScript Record.

enum ExampleEnum {
    Foo = 'foo',
    Bar = 'bar',
}

type ExampleRecord = Record<ExampleEnum, string>;
// {
//     foo: string;
//     bar: string;
// }

const exampleRecord: ExampleRecord = {
    [ExampleEnum.Foo]: 'foo',
}
// Property '[ExampleEnum.Bar]' is missing in type '{ foo: string; }' but required in type 'ExampleRecord'.

If the schema created by z.record() is used for a property on another object schema, the property's type is inferred as a Partial type.

const tempSchema = z.object({baz: exampleRecordSchema});

type Temp = z.infer<typeof tempSchema>;
// {
//     baz: Partial<Record<"foo" | "bar", string>>;
// }
hayata-suenaga commented 1 year ago

As the high level implementation idea, I have the following in mind.

Armadillidiid commented 10 months ago

Just encountered the same issue.

lunelson commented 10 months ago

I'm using this workaround in the meantime:

export function isPlainObject(value: unknown): value is Record<string | number | symbol, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date);
}

export function zodStrictRecord<K extends z.ZodType<string | number | symbol>, V extends z.ZodTypeAny>(
  zKey: K,
  zValue: V,
) {
  return z.custom<Record<z.infer<K>, z.infer<V>>>((input: unknown) => {
    return (
      isPlainObject(input) &&
      Object.entries(input).every(
        ([key, value]) => zKey.safeParse(key).success && zValue.safeParse(value).success,
      )
    );
  }, 'zodStrictRecord: error');
}
patricio-hondagneu-simplisafe commented 10 months ago

Facing the same issue here.

ykolomiets commented 10 months ago

@lunelson your workaround won't catch the missing keys:

const schema = zodStrictRecord(z.enum(["field1", "field2"]), z.string());
const parsed = schema.parse({})
// type { field1: string; field2: string; }

I suggest using superRefine to check that all keys are present:

import { z } from "zod";
import difference from "lodash/difference.js"

export function isPlainObject(value: unknown): value is Record<string | number | symbol, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date);
}

export function zStrictRecord<K extends z.ZodEnum<[string, ...string[]]>, V extends z.ZodTypeAny>(
  zKey: K,
  zValue: V,
) {
  return z.record(zKey, zValue)
    .superRefine((input, ctx): input is Record<z.infer<K>, z.infer<V>> => {
      const inputKeys = Object.keys(input);
      const missedKeys = difference(zKey.options, inputKeys);
      if (missedKeys.length > 0) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          message: `missing required keys: ${missedKeys.join(", ")}`,
          fatal: true,
        })
      }
      return z.NEVER;
    });
}
lunelson commented 10 months ago

@ykolomiets nice catch :). In that case, to be able to handle different types of keys, you'd have to branch on whether the key is an enum, and whether any of the elements in the enum is another schema, or something literal πŸ‘πŸΌ, and this could in theory be nested even further ... probably need a recursive helper for this πŸ€”

lunelson commented 10 months ago

Untested theory: maybe the problem is just that the inferred type is wrong, but that the z.record schema still actually validates correctly... in that case a simpler use of z.custom can correct the type:

Nope, this doesn't work either although it's a cleaner way of implementing my first solution 🀦🏼

export function zRecord<K extends z.ZodType<string | number | symbol>, V extends z.ZodTypeAny>(
    zKey: K,
    zValue: V,
) {
    return z.custom<Record<z.infer<K>, z.infer<V>>>((input: unknown) => z.record(zKey, zValue).safeParse(input).success, 'zodStrictRecord: error');
}
tadeaspetak commented 9 months ago

Just encountered a reverse issue of this: When using z.record(z.nativeEnum(...), z.boolean()), the resulting record is not partial but exhaustive instead. Would be awesome if the behaviour of z.enum and z.nativeEnum when used as record keys could be consistent, and if there were a way to make records partial or exhaustive as necessary.

EDIT 2024-01-25: Seen this solution somewhere should it help anyone z.record(a, b).transform((x) => x as typeof x extends Partial<infer T> ? T : never);

kafkas commented 9 months ago

+1

JuSfrei commented 9 months ago

+1

aseemk commented 8 months ago

+1 to this issue. Here's how I achieved this with my own workaround. It both gives an accurate/helpful strongly-typed TypeScript type (via z.infer), achieves exhaustive type checking, and supports default values β€” with no manual transform, refine/superRefine, or custom needed β€”Β by using z.object instead:

/**
 * Zod's `record` when used with an `enum` key type unfortunately makes every key & value optional,
 * with no ability to override that or e.g. set `default` values:
 * https://github.com/colinhacks/zod/issues/2623
 *
 * So this helper generates an `object` schema instead, with every key required by default and
 * mapped to the given value schema. You can then call `partial()` to behave like Zod's `record`,
 * but you can also set `default()` on the value schema to have a default value per omitted key.
 * This also achieves an exhaustive key check similar to TypeScript's `Record` type.
 */
export function zodRecordWithEnum<
  EnumSchema extends ZodEnum<any>,
  EnumType extends z.infer<EnumSchema>,
  ValueSchema extends ZodTypeAny,
>(enumSchema: EnumSchema, valueSchema: ValueSchema) {
  return z.object(
    // TODO: Why is this explicit generic parameter needed / `enumSchema.options` typed as `any`?
    _zodShapeWithKeysAndValue<EnumType, ValueSchema>(
      enumSchema.options,
      valueSchema,
    ),
  )
}

function _zodShapeWithKeysAndValue<
  KeyType extends string | number | symbol,
  ValueSchema extends ZodTypeAny,
>(keys: KeyType[], valueSchema: ValueSchema) {
  return Object.fromEntries(
    keys.map(key => [key, valueSchema]),
    // HACK: This explicit cast is needed bc `Object.fromEntries()` loses precise typing of keys
    // (even with `as [keyof PropsType, ValueType][]` on the `Object.keys(...).map(...)` above).
    // Wish Zod had a helper for mapped types similar to TypeScript.
  ) as {
    [Key in KeyType]: ValueSchema
  }
}

Example usage:

const groupEnum = z.enum(['FOO', 'BAR'])
export type Group = z.infer<typeof groupEnum> // "FOO" | "BAR"

const membersSchema = z.array(z.string()).optional().default([])
export type Members = z.infer<typeof membersSchema> // string[]

export const groupMembersSchema = zodRecordWithEnum(
  groupEnum,
  membersSchema,
)
export type GroupMembers = z.infer<typeof groupMembersSchema>
// ^-- { FOO: string[], BAR: string[] }

Both FYI if helpful to anyone else, and feedback welcome if I'm missing anything! Thanks, and thanks @hayata-suenaga for filing this issue. =)

(P. S. I'm really loving Zod despite issues like this! πŸ™‚ Thanks very much @colinhacks for such a great library. ❀️)

macmillen commented 5 months ago

+1 I am using a graphql-codegen-typescript-validation-schema to generate zod enums like this:

export const PosTypeEnumSchema = z.enum(['external', 'online', 'standard', 'telephone']);

That way I don't need to define all of my enum schemas manually.

Now I need a record with these enums as a key type and I don't want to re-define these keys by hand but instead use the generated z.enum.

martijnarts commented 1 month ago

Wouldn't the correct behaviour here be splitting out the record and the Partial<Record<...>> into separate Zod types? Would this be more correct Typescript typings for the two desired things here?

const R = z.record(z.brand("foo"), z.string());
type IR = z.infer<typeof P>;
// IR = Record<Branded, string>

const P = z.keyedObject(z.brand("foo"), z.string());
type IP = z.infer<typeof P>;
// IP = { [key: Branded]: string }

Considering that Typescript's Record<string, string> is (confusingly) more like Partial<Record<string, string>>, when compared with Typescript's Record<"foo" | "bar", string>.

FrankSandqvist commented 1 month ago

I solved it by just doing a reduce. Not sure if it's very elegant, but it does the trick. (wanted a record of languages with required keys, languageSchema being an enum ["sv", "fi", "en"])

export const l10nTextSchema = z.object(
  languageSchema.options.reduce((acc, lang) => {
    acc[lang] = z.string()
    return acc
  }, {} as Record<typeof languageSchema._type, ReturnType<typeof z.string>>)
)
alexburner commented 1 week ago

I too just encountered this

I assumed I was doing something wrong (until I found this thread here, hi πŸ‘‹)

The docs imply this should generate Record<KeyType, ValueType> instead of Partial<Record<KeyType, ValueType>>

Screenshot 2024-10-10 at 10 44 06β€―AM

It seems worth at least adding a note to the docs about this case, similar to A note on numerical keys?