Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/zod/src/v4/classic/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,29 @@ test("z.record", () => {
const d = z.record(z.enum(["a", "b"]).or(z.never()), z.string());
type d = z.output<typeof d>;
expectTypeOf<d>().toEqualTypeOf<Record<"a" | "b", string>>();

// literal union keys
const e = z.record(z.union([z.literal("a"), z.literal(0)]), z.string());
type e = z.output<typeof e>;
expectTypeOf<e>().toEqualTypeOf<Record<"a" | 0, string>>();
expect(z.parse(e, { a: "hello", 0: "world" })).toEqual({
a: "hello",
0: "world",
});

// TypeScript enum keys
enum Enum {
A = 0,
B = "hi",
}

const f = z.record(z.enum(Enum), z.string());
type f = z.output<typeof f>;
expectTypeOf<f>().toEqualTypeOf<Record<Enum, string>>();
expect(z.parse(f, { [Enum.A]: "hello", [Enum.B]: "world" })).toEqual({
[Enum.A]: "hello",
[Enum.B]: "world",
});
});

test("z.map", () => {
Expand Down
109 changes: 100 additions & 9 deletions packages/zod/src/v4/classic/tests/record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,28 @@ test("type inference", () => {
const recordWithEnumKeys = z.record(z.enum(["Tuna", "Salmon"]), z.string());
type recordWithEnumKeys = z.infer<typeof recordWithEnumKeys>;

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

const recordWithLiteralUnionKeys = z.record(z.union([z.literal("Tuna"), z.literal("Salmon")]), z.string());
const recordWithLiteralUnionKeys = z.record(
z.union([z.literal("Tuna"), z.literal("Salmon"), z.literal(21)]),
z.string()
);
type recordWithLiteralUnionKeys = z.infer<typeof recordWithLiteralUnionKeys>;

enum Enum {
Tuna = 0,
Salmon = "Shark",
}

const recordWithTypescriptEnum = z.record(z.enum(Enum), z.string());
type recordWithTypescriptEnum = z.infer<typeof recordWithTypescriptEnum>;

expectTypeOf<booleanRecord>().toEqualTypeOf<Record<string, boolean>>();
expectTypeOf<recordWithEnumKeys>().toEqualTypeOf<Record<"Tuna" | "Salmon", string>>();
expectTypeOf<recordWithLiteralKey>().toEqualTypeOf<Record<"Tuna" | "Salmon", string>>();
expectTypeOf<recordWithLiteralUnionKeys>().toEqualTypeOf<Record<"Tuna" | "Salmon", string>>();
expectTypeOf<recordWithLiteralKey>().toEqualTypeOf<Record<"Tuna" | "Salmon" | 21, string>>();
expectTypeOf<recordWithLiteralUnionKeys>().toEqualTypeOf<Record<"Tuna" | "Salmon" | 21, string>>();
expectTypeOf<recordWithTypescriptEnum>().toEqualTypeOf<Record<Enum, string>>();
});

test("enum exhaustiveness", () => {
Expand Down Expand Up @@ -64,14 +76,76 @@ test("enum exhaustiveness", () => {
`);
});

test("typescript enum exhaustiveness", () => {
enum BigFish {
Tuna = 0,
Salmon = "Shark",
}

const schema = z.record(z.enum(BigFish), z.string());
const value = {
[BigFish.Tuna]: "asdf",
[BigFish.Salmon]: "asdf",
};

expect(schema.parse(value)).toEqual(value);

expect(schema.safeParse({ [BigFish.Tuna]: "asdf", [BigFish.Salmon]: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"code": "unrecognized_keys",
"keys": [
"Trout"
],
"path": [],
"message": "Unrecognized key: \\"Trout\\""
}
]],
"success": false,
}
`);
expect(schema.safeParse({ [BigFish.Tuna]: "asdf" })).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"expected": "string",
"code": "invalid_type",
"path": [
"Shark"
],
"message": "Invalid input: expected string, received undefined"
}
]],
"success": false,
}
`);
expect(schema.safeParse({ [BigFish.Salmon]: "asdf" })).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
"expected": "string",
"code": "invalid_type",
"path": [
0
],
"message": "Invalid input: expected string, received undefined"
}
]],
"success": false,
}
`);
});

test("literal exhaustiveness", () => {
const schema = z.record(z.literal(["Tuna", "Salmon"]), z.string());
const schema = z.record(z.literal(["Tuna", "Salmon", 21]), z.string());
schema.parse({
Tuna: "asdf",
Salmon: "asdf",
21: "asdf",
});

expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", 21: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
Expand All @@ -96,6 +170,14 @@ test("literal exhaustiveness", () => {
"Salmon"
],
"message": "Invalid input: expected string, received undefined"
},
{
"expected": "string",
"code": "invalid_type",
"path": [
21
],
"message": "Invalid input: expected string, received undefined"
}
]],
"success": false,
Expand Down Expand Up @@ -143,13 +225,14 @@ test("pipe exhaustiveness", () => {
});

test("union exhaustiveness", () => {
const schema = z.record(z.union([z.literal("Tuna"), z.literal("Salmon")]), z.string());
expect(schema.parse({ Tuna: "asdf", Salmon: "asdf" })).toEqual({
const schema = z.record(z.union([z.literal("Tuna"), z.literal("Salmon"), z.literal(21)]), z.string());
expect(schema.parse({ Tuna: "asdf", Salmon: "asdf", 21: "asdf" })).toEqual({
Tuna: "asdf",
Salmon: "asdf",
21: "asdf",
});

expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
expect(schema.safeParse({ Tuna: "asdf", Salmon: "asdf", 21: "asdf", Trout: "asdf" })).toMatchInlineSnapshot(`
{
"error": [ZodError: [
{
Expand All @@ -174,6 +257,14 @@ test("union exhaustiveness", () => {
"Salmon"
],
"message": "Invalid input: expected string, received undefined"
},
{
"expected": "string",
"code": "invalid_type",
"path": [
21
],
"message": "Invalid input: expected string, received undefined"
}
]],
"success": false,
Expand Down
4 changes: 3 additions & 1 deletion packages/zod/src/v4/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2593,8 +2593,10 @@ export const $ZodRecord: core.$constructor<$ZodRecord> = /*@__PURE__*/ core.$con
if (def.keyType._zod.values) {
const values = def.keyType._zod.values!;
payload.value = {};
const recordKeys = new Set<string | symbol>();
for (const key of values) {
if (typeof key === "string" || typeof key === "number" || typeof key === "symbol") {
recordKeys.add(typeof key === "number" ? key.toString() : key);
const result = def.valueType._zod.run({ value: input[key], issues: [] }, ctx);

if (result instanceof Promise) {
Expand All @@ -2617,7 +2619,7 @@ export const $ZodRecord: core.$constructor<$ZodRecord> = /*@__PURE__*/ core.$con

let unrecognized!: string[];
for (const key in input) {
if (!values.has(key)) {
if (!recordKeys.has(key)) {
unrecognized = unrecognized ?? [];
unrecognized.push(key);
}
Expand Down
23 changes: 23 additions & 0 deletions packages/zod/src/v4/mini/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,29 @@ test("z.record", () => {
expect(() => z.parse(c, { a: "hello", b: "world" })).toThrow();
// extra keys
expect(() => z.parse(c, { a: "hello", b: "world", c: "world", d: "world" })).toThrow();

// literal union keys
const d = z.record(z.union([z.literal("a"), z.literal(0)]), z.string());
type d = z.output<typeof d>;
expectTypeOf<d>().toEqualTypeOf<Record<"a" | 0, string>>();
expect(z.parse(d, { a: "hello", 0: "world" })).toEqual({
a: "hello",
0: "world",
});

// TypeScript enum keys
enum Enum {
A = 0,
B = "hi",
}

const e = z.record(z.enum(Enum), z.string());
type e = z.output<typeof e>;
expectTypeOf<e>().toEqualTypeOf<Record<Enum, string>>();
expect(z.parse(e, { [Enum.A]: "hello", [Enum.B]: "world" })).toEqual({
[Enum.A]: "hello",
[Enum.B]: "world",
});
});

test("z.map", () => {
Expand Down