Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hayata-suenaga opened this issue Aug 1, 2023 · 12 comments

Comments

@hayata-suenaga
Copy link
Sponsor

hayata-suenaga commented Aug 1, 2023

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
Copy link
Sponsor Author

hayata-suenaga commented Aug 1, 2023

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

  • Add additional checks for an enum and union in the type definition of RecordType
  • In the _parse method of ZodRecordDef, check if this._def.keyType is ZodEnum or ZodUnion
  • If the keyType is an enum or union schema, check if all values in the enum or union are present as keys in the input data.

@Armadillidiid
Copy link

Just encountered the same issue.

@lunelson
Copy link

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

Facing the same issue here.

@ykolomiets
Copy link

@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
Copy link

@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
Copy link

lunelson commented Dec 24, 2023

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
Copy link

tadeaspetak commented Jan 8, 2024

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
Copy link

kafkas commented Jan 16, 2024

+1

1 similar comment
@JuSfrei
Copy link

JuSfrei commented Jan 24, 2024

+1

@aseemk
Copy link

aseemk commented Feb 6, 2024

+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
Copy link

+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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants