Skip to content

Commit d44253d

Browse files
Komoszekcolinhacks
andauthored
Add support for number literal and TypeScript's enum keys in z.record (#5334)
* Add support for number literal keys and TypeScript enums in z.record * Tweak --------- Co-authored-by: Colin McDonnell <[email protected]>
1 parent 02ea4c8 commit d44253d

File tree

4 files changed

+151
-12
lines changed

4 files changed

+151
-12
lines changed

packages/zod/src/v4/classic/tests/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,29 @@ test("z.record", () => {
302302
const d = z.record(z.enum(["a", "b"]).or(z.never()), z.string());
303303
type d = z.output<typeof d>;
304304
expectTypeOf<d>().toEqualTypeOf<Record<"a" | "b", string>>();
305+
306+
// literal union keys
307+
const e = z.record(z.union([z.literal("a"), z.literal(0)]), z.string());
308+
type e = z.output<typeof e>;
309+
expectTypeOf<e>().toEqualTypeOf<Record<"a" | 0, string>>();
310+
expect(z.parse(e, { a: "hello", 0: "world" })).toEqual({
311+
a: "hello",
312+
0: "world",
313+
});
314+
315+
// TypeScript enum keys
316+
enum Enum {
317+
A = 0,
318+
B = "hi",
319+
}
320+
321+
const f = z.record(z.enum(Enum), z.string());
322+
type f = z.output<typeof f>;
323+
expectTypeOf<f>().toEqualTypeOf<Record<Enum, string>>();
324+
expect(z.parse(f, { [Enum.A]: "hello", [Enum.B]: "world" })).toEqual({
325+
[Enum.A]: "hello",
326+
[Enum.B]: "world",
327+
});
305328
});
306329

307330
test("z.map", () => {

packages/zod/src/v4/classic/tests/record.test.ts

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,28 @@ test("type inference", () => {
88
const recordWithEnumKeys = z.record(z.enum(["Tuna", "Salmon"]), z.string());
99
type recordWithEnumKeys = z.infer<typeof recordWithEnumKeys>;
1010

11-
const recordWithLiteralKey = z.record(z.literal(["Tuna", "Salmon"]), z.string());
11+
const recordWithLiteralKey = z.record(z.literal(["Tuna", "Salmon", 21]), z.string());
1212
type recordWithLiteralKey = z.infer<typeof recordWithLiteralKey>;
1313

14-
const recordWithLiteralUnionKeys = z.record(z.union([z.literal("Tuna"), z.literal("Salmon")]), z.string());
14+
const recordWithLiteralUnionKeys = z.record(
15+
z.union([z.literal("Tuna"), z.literal("Salmon"), z.literal(21)]),
16+
z.string()
17+
);
1518
type recordWithLiteralUnionKeys = z.infer<typeof recordWithLiteralUnionKeys>;
1619

20+
enum Enum {
21+
Tuna = 0,
22+
Salmon = "Shark",
23+
}
24+
25+
const recordWithTypescriptEnum = z.record(z.enum(Enum), z.string());
26+
type recordWithTypescriptEnum = z.infer<typeof recordWithTypescriptEnum>;
27+
1728
expectTypeOf<booleanRecord>().toEqualTypeOf<Record<string, boolean>>();
1829
expectTypeOf<recordWithEnumKeys>().toEqualTypeOf<Record<"Tuna" | "Salmon", string>>();
19-
expectTypeOf<recordWithLiteralKey>().toEqualTypeOf<Record<"Tuna" | "Salmon", string>>();
20-
expectTypeOf<recordWithLiteralUnionKeys>().toEqualTypeOf<Record<"Tuna" | "Salmon", string>>();
30+
expectTypeOf<recordWithLiteralKey>().toEqualTypeOf<Record<"Tuna" | "Salmon" | 21, string>>();
31+
expectTypeOf<recordWithLiteralUnionKeys>().toEqualTypeOf<Record<"Tuna" | "Salmon" | 21, string>>();
32+
expectTypeOf<recordWithTypescriptEnum>().toEqualTypeOf<Record<Enum, string>>();
2133
});
2234

2335
test("enum exhaustiveness", () => {
@@ -64,14 +76,76 @@ test("enum exhaustiveness", () => {
6476
`);
6577
});
6678

79+
test("typescript enum exhaustiveness", () => {
80+
enum BigFish {
81+
Tuna = 0,
82+
Salmon = "Shark",
83+
}
84+
85+
const schema = z.record(z.enum(BigFish), z.string());
86+
const value = {
87+
[BigFish.Tuna]: "asdf",
88+
[BigFish.Salmon]: "asdf",
89+
};
90+
91+
expect(schema.parse(value)).toEqual(value);
92+
93+
expect(schema.safeParse({ [BigFish.Tuna]: "asdf", [BigFish.Salmon]: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
94+
{
95+
"error": [ZodError: [
96+
{
97+
"code": "unrecognized_keys",
98+
"keys": [
99+
"Trout"
100+
],
101+
"path": [],
102+
"message": "Unrecognized key: \\"Trout\\""
103+
}
104+
]],
105+
"success": false,
106+
}
107+
`);
108+
expect(schema.safeParse({ [BigFish.Tuna]: "asdf" })).toMatchInlineSnapshot(`
109+
{
110+
"error": [ZodError: [
111+
{
112+
"expected": "string",
113+
"code": "invalid_type",
114+
"path": [
115+
"Shark"
116+
],
117+
"message": "Invalid input: expected string, received undefined"
118+
}
119+
]],
120+
"success": false,
121+
}
122+
`);
123+
expect(schema.safeParse({ [BigFish.Salmon]: "asdf" })).toMatchInlineSnapshot(`
124+
{
125+
"error": [ZodError: [
126+
{
127+
"expected": "string",
128+
"code": "invalid_type",
129+
"path": [
130+
0
131+
],
132+
"message": "Invalid input: expected string, received undefined"
133+
}
134+
]],
135+
"success": false,
136+
}
137+
`);
138+
});
139+
67140
test("literal exhaustiveness", () => {
68-
const schema = z.record(z.literal(["Tuna", "Salmon"]), z.string());
141+
const schema = z.record(z.literal(["Tuna", "Salmon", 21]), z.string());
69142
schema.parse({
70143
Tuna: "asdf",
71144
Salmon: "asdf",
145+
21: "asdf",
72146
});
73147

74-
expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
148+
expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", 21: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
75149
{
76150
"error": [ZodError: [
77151
{
@@ -96,6 +170,14 @@ test("literal exhaustiveness", () => {
96170
"Salmon"
97171
],
98172
"message": "Invalid input: expected string, received undefined"
173+
},
174+
{
175+
"expected": "string",
176+
"code": "invalid_type",
177+
"path": [
178+
21
179+
],
180+
"message": "Invalid input: expected string, received undefined"
99181
}
100182
]],
101183
"success": false,
@@ -143,13 +225,14 @@ test("pipe exhaustiveness", () => {
143225
});
144226

145227
test("union exhaustiveness", () => {
146-
const schema = z.record(z.union([z.literal("Tuna"), z.literal("Salmon")]), z.string());
147-
expect(schema.parse({ Tuna: "asdf", Salmon: "asdf" })).toEqual({
228+
const schema = z.record(z.union([z.literal("Tuna"), z.literal("Salmon"), z.literal(21)]), z.string());
229+
expect(schema.parse({ Tuna: "asdf", Salmon: "asdf", 21: "asdf" })).toEqual({
148230
Tuna: "asdf",
149231
Salmon: "asdf",
232+
21: "asdf",
150233
});
151234

152-
expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
235+
expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", 21: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
153236
{
154237
"error": [ZodError: [
155238
{
@@ -174,6 +257,14 @@ test("union exhaustiveness", () => {
174257
"Salmon"
175258
],
176259
"message": "Invalid input: expected string, received undefined"
260+
},
261+
{
262+
"expected": "string",
263+
"code": "invalid_type",
264+
"path": [
265+
21
266+
],
267+
"message": "Invalid input: expected string, received undefined"
177268
}
178269
]],
179270
"success": false,

packages/zod/src/v4/core/schemas.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2603,11 +2603,13 @@ export const $ZodRecord: core.$constructor<$ZodRecord> = /*@__PURE__*/ core.$con
26032603

26042604
const proms: Promise<any>[] = [];
26052605

2606-
if (def.keyType._zod.values) {
2607-
const values = def.keyType._zod.values!;
2606+
const values = def.keyType._zod.values;
2607+
if (values) {
26082608
payload.value = {};
2609+
const recordKeys = new Set<string | symbol>();
26092610
for (const key of values) {
26102611
if (typeof key === "string" || typeof key === "number" || typeof key === "symbol") {
2612+
recordKeys.add(typeof key === "number" ? key.toString() : key);
26112613
const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx);
26122614

26132615
if (result instanceof Promise) {
@@ -2630,7 +2632,7 @@ export const $ZodRecord: core.$constructor<$ZodRecord> = /*@__PURE__*/ core.$con
26302632

26312633
let unrecognized!: string[];
26322634
for (const key in input) {
2633-
if (!values.has(key)) {
2635+
if (!recordKeys.has(key)) {
26342636
unrecognized = unrecognized ?? [];
26352637
unrecognized.push(key);
26362638
}

packages/zod/src/v4/mini/tests/index.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,29 @@ test("z.record", () => {
296296
expect(() => z.parse(c, { a: "hello", b: "world" })).toThrow();
297297
// extra keys
298298
expect(() => z.parse(c, { a: "hello", b: "world", c: "world", d: "world" })).toThrow();
299+
300+
// literal union keys
301+
const d = z.record(z.union([z.literal("a"), z.literal(0)]), z.string());
302+
type d = z.output<typeof d>;
303+
expectTypeOf<d>().toEqualTypeOf<Record<"a" | 0, string>>();
304+
expect(z.parse(d, { a: "hello", 0: "world" })).toEqual({
305+
a: "hello",
306+
0: "world",
307+
});
308+
309+
// TypeScript enum keys
310+
enum Enum {
311+
A = 0,
312+
B = "hi",
313+
}
314+
315+
const e = z.record(z.enum(Enum), z.string());
316+
type e = z.output<typeof e>;
317+
expectTypeOf<e>().toEqualTypeOf<Record<Enum, string>>();
318+
expect(z.parse(e, { [Enum.A]: "hello", [Enum.B]: "world" })).toEqual({
319+
[Enum.A]: "hello",
320+
[Enum.B]: "world",
321+
});
299322
});
300323

301324
test("z.map", () => {

0 commit comments

Comments
 (0)