Skip to content
Merged
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
18 changes: 16 additions & 2 deletions src/schema-writer/field-emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
}

Expand Down Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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})`;
}
73 changes: 67 additions & 6 deletions test/schema-writer/field-emitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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"',
);
});
});
});
19 changes: 19 additions & 0 deletions test/schema-writer/round-trip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
36 changes: 30 additions & 6 deletions test/schema-writer/writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,27 @@ describe("writeSchema", () => {
"export type Contact = z.infer<typeof contactSchema>;",
);
});

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", () => {
Expand Down Expand Up @@ -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())");
});
});

Expand Down