Open hayata-suenaga opened 1 year ago
As the high level implementation idea, I have the following in mind.
RecordType
_parse
method of ZodRecordDef
, check if this._def.keyType
is ZodEnum
or ZodUnion
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
.
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>
.
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>>)
)
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>>
It seems worth at least adding a note to the docs about this case, similar to A note on numerical keys
?
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 typez.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 of
z.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
.The following example code illustrates the behavior of TypeScript Record.
If the schema created by
z.record()
is used for a property on another object schema, the property's type is inferred as aPartial
type.