@abyrd9/zod-form-data
Version:
A Zod-based form data validation library
112 lines (104 loc) • 2.91 kB
text/typescript
import { z } from "zod/v4";
const BoolAsString = z
.string()
.regex(
/^(true|false|on)$/,
'Must be a boolean string ("true", "false", or "on")'
)
.transform((value) => value === "true" || value === "on");
const IntAsString = z
.string()
.regex(/^-?\d+$/, "Must be an integer string")
.transform((val) => Number.parseInt(val, 10));
const NumAsString = z
.string()
.regex(/^-?\d*\.?\d+$/, "Must be a number string")
.transform(Number);
const DateAsString = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Must be a date string (YYYY-MM-DD)")
.transform((val) => new Date(val));
export function coerceFormData<Schema extends z.ZodType>(
type: Schema
): z.ZodType<z.output<Schema>> {
let schema: z.ZodType = type;
switch (schema.def.type) {
case "string":
case "literal":
case "enum":
schema = z
.any()
.overwrite((value) => z.coerce.string().parse(value))
.pipe(type);
break;
case "bigint":
schema = z
.any()
.overwrite((value) => IntAsString.parse(value))
.pipe(type);
break;
case "number":
schema = z
.any()
.overwrite((value) => {
// First try to coerce to number
const coerced = z.coerce.number().safeParse(value);
if (coerced.success) return coerced.data;
// If that fails, try parsing as string
return NumAsString.parse(value);
})
.pipe(type);
break;
case "boolean":
schema = z
.any()
.overwrite((value) => BoolAsString.parse(value))
.pipe(type);
break;
case "date":
schema = z
.any()
.overwrite((value) => DateAsString.parse(value))
.pipe(type);
break;
case "array":
schema = z.preprocess(
(val) => (Array.isArray(val) ? val : val === undefined ? [] : [val]),
type
);
break;
case "union": {
// Try to coerce into each union option by reusing coerceFormData recursively
const options = (schema as z.ZodUnion<z.ZodType[]>).options;
schema = z
.any()
.overwrite((value) => {
for (const opt of options) {
const coerced = coerceFormData(opt).safeParse(value);
if (coerced.success) return coerced.data;
}
return value;
})
.pipe(type);
break;
}
case "nullable":
case "optional":
schema = z.preprocess((val) => (val === "" ? null : val), type);
break;
case "file":
schema = z
.any()
.overwrite((value) =>
value instanceof File ? value : new File([value], value.name)
)
.pipe(type);
break;
default:
console.error(
`Zod type not handled in coerceFormData: ${schema.def.type}`
);
break;
}
return schema as z.ZodType<z.output<Schema>>;
}