zod
zod copied to clipboard
`z.record` with a key of a union or enum schema results in a partial record
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.
- https://github.com/colinhacks/zod/issues/2320
- https://github.com/colinhacks/zod/issues/2448
- https://github.com/colinhacks/zod/issues/55
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>>;
// }
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 ofZodRecordDef
, check ifthis._def.keyType
isZodEnum
orZodUnion
- 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.
Just encountered the same issue.
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');
}
Facing the same issue here.
@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;
});
}
@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 🤔
~~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');
}
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);
+1
+1
+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. ❤️)
+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
.