diff --git a/src/schema-writer/field-emitter.ts b/src/schema-writer/field-emitter.ts index e9c77bf..b0e29e0 100644 --- a/src/schema-writer/field-emitter.ts +++ b/src/schema-writer/field-emitter.ts @@ -11,17 +11,18 @@ const SUPPORTED_TYPES = new Set([ "array", "tuple", "object", + "record", ]); /** * Emits a Zod v4 expression string for a single FieldDescriptor. - * Throws on unsupported types (union, record). + * Throws on unsupported types (union). */ export function emitField(field: FieldDescriptor): string { if (!SUPPORTED_TYPES.has(field.type)) { throw new Error( `Unsupported field type "${field.type}" for field "${field.name}". ` + - "Only string, number, boolean, date, enum, array, tuple, and object are supported.", + "Only string, number, boolean, date, enum, array, tuple, object, and record are supported.", ); } @@ -60,6 +61,8 @@ function emitBaseExpression(field: FieldDescriptor): string { return emitTuple(field); case "object": return emitObject(field); + case "record": + return emitRecord(field); default: throw new Error(`Unexpected field type: ${field.type}`); } @@ -187,3 +190,14 @@ function emitObject(field: FieldDescriptor): string { }); return `z.object({ ${entries.join(", ")} })`; } + +function emitRecord(field: FieldDescriptor): string { + if (field.metadata.kind !== "record") { + throw new Error( + `Field "${field.name}" has type "record" but metadata kind is "${field.metadata.kind}"`, + ); + } + + const valueExpr = emitField(field.metadata.valueDescriptor); + return `z.record(z.string(), ${valueExpr})`; +} diff --git a/test/schema-writer/field-emitter.test.ts b/test/schema-writer/field-emitter.test.ts index 662a23e..e9bac1b 100644 --- a/test/schema-writer/field-emitter.test.ts +++ b/test/schema-writer/field-emitter.test.ts @@ -312,6 +312,68 @@ describe("emitField", () => { }); }); + describe("record fields", () => { + it("emits z.record() with scalar value", () => { + const field = makeField({ + type: "record", + metadata: { + kind: "record", + valueDescriptor: makeField({ + name: "value", + type: "number", + metadata: { kind: "number" }, + }), + }, + }); + expect(emitField(field)).toBe("z.record(z.string(), z.number())"); + }); + + it("emits z.record() with enum value", () => { + const field = makeField({ + type: "record", + metadata: { + kind: "record", + valueDescriptor: makeField({ + name: "value", + type: "enum", + metadata: { kind: "enum", values: ["low", "medium", "high"] }, + }), + }, + }); + expect(emitField(field)).toBe( + 'z.record(z.string(), z.enum(["low", "medium", "high"]))', + ); + }); + + it("emits optional record", () => { + const field = makeField({ + type: "record", + isOptional: true, + metadata: { + kind: "record", + valueDescriptor: makeField({ + name: "value", + type: "string", + metadata: { kind: "string" }, + }), + }, + }); + expect(emitField(field)).toBe( + "z.record(z.string(), z.string()).optional()", + ); + }); + + it("throws when metadata kind does not match record type", () => { + const field = makeField({ + type: "record", + metadata: { kind: "string" }, + }); + expect(() => emitField(field)).toThrow( + 'Field "test" has type "record" but metadata kind is "string"', + ); + }); + }); + describe("optional and nullable wrapping", () => { it("wraps with .optional()", () => { const field = makeField({ @@ -562,15 +624,14 @@ describe("emitField", () => { expect(() => emitField(field)).toThrow('Unsupported field type "union"'); }); - it("throws on record type", () => { + it("throws on record type with mismatched metadata", () => { const field = makeField({ type: "record", - metadata: { - kind: "record", - valueDescriptor: makeField({ name: "value" }), - }, + metadata: { kind: "string" }, }); - expect(() => emitField(field)).toThrow('Unsupported field type "record"'); + expect(() => emitField(field)).toThrow( + 'Field "test" has type "record" but metadata kind is "string"', + ); }); }); }); diff --git a/test/schema-writer/round-trip.test.ts b/test/schema-writer/round-trip.test.ts index 673ce6d..a941274 100644 --- a/test/schema-writer/round-trip.test.ts +++ b/test/schema-writer/round-trip.test.ts @@ -343,6 +343,25 @@ describe("round-trip: schema -> introspect -> writeSchema -> eval -> introspect" } }); + it("round-trips a record of numbers", () => { + const schema = z.object({ + scores: z.record(z.string(), z.number()), + }); + const { descriptor1, descriptor2 } = roundTrip(schema); + + expect(descriptor2.fields[0].type).toBe("record"); + expect(descriptor2.fields[0].name).toBe(descriptor1.fields[0].name); + expect(descriptor2.fields[0].metadata.kind).toBe("record"); + if ( + descriptor2.fields[0].metadata.kind === "record" && + descriptor1.fields[0].metadata.kind === "record" + ) { + expect(descriptor2.fields[0].metadata.valueDescriptor.type).toBe( + descriptor1.fields[0].metadata.valueDescriptor.type, + ); + } + }); + it("round-trips a complex multi-field schema", () => { const schema = z.object({ name: z.string().min(1), diff --git a/test/schema-writer/writer.test.ts b/test/schema-writer/writer.test.ts index 5dad15f..c47c927 100644 --- a/test/schema-writer/writer.test.ts +++ b/test/schema-writer/writer.test.ts @@ -127,6 +127,27 @@ describe("writeSchema", () => { "export type Contact = z.infer;", ); }); + + it("emits record type through writer", () => { + const form = makeForm({ + fields: [ + makeField({ + name: "scores", + type: "record", + metadata: { + kind: "record", + valueDescriptor: makeField({ + name: "value", + type: "number", + metadata: { kind: "number" }, + }), + }, + }), + ], + }); + const result = writeSchema({ form }); + expect(result.code).toContain("scores: z.record(z.string(), z.number())"); + }); }); describe("tuple fields", () => { @@ -209,22 +230,25 @@ describe("writeSchema", () => { ); }); - it("throws on unsupported record type", () => { + it("emits record type through writer", () => { const form = makeForm({ fields: [ makeField({ - name: "map", + name: "scores", type: "record", metadata: { kind: "record", - valueDescriptor: makeField({ name: "value" }), + valueDescriptor: makeField({ + name: "value", + type: "number", + metadata: { kind: "number" }, + }), }, }), ], }); - expect(() => writeSchema({ form })).toThrow( - 'Unsupported field type "record"', - ); + const result = writeSchema({ form }); + expect(result.code).toContain("scores: z.record(z.string(), z.number())"); }); });