Skip to content

Instantly share code, notes, and snippets.

@composite
Last active March 18, 2025 05:41
Show Gist options
  • Save composite/538f1424e4bd234e8e668e2b0b4d7bd0 to your computer and use it in GitHub Desktop.
Save composite/538f1424e4bd234e8e668e2b0b4d7bd0 to your computer and use it in GitHub Desktop.
zod Utility: Between FormData and plain object!
import { z } from 'zod';
import { formDataToObject, createFormDataPaths } from './zod-util';
const schema = z.object({
username: z.string(),
location: z.object({
latitude: z.number(),
longitude: z.number(),
}),
strings: z.array(z.object({ value: z.string() })),
});
console.log(createFormDataPaths(schema));
/*
{
"username": "username",
"location.latitude": "location.latitude",
"location.longitude": "location.longitude",
"strings[].value": "strings[].value"
}
*/
const formData = new FormData();
formData.set('foo', 'foo');
formData.set('num1', '1');
formData.set('num2', '2');
formData.set('num3', '3');
formData.set('bar.foo', 'FOO');
formData.set('bar.bar', 'BAR');
formData.set('bar.baz', 'BAZ');
formData.append('baz[]', 'baz1');
formData.append('baz[]', 'baz2');
formData.append('baz[]', 'baz3');
formData.set('nums[2]', 'num1');
formData.set('nums[1]', 'num2');
formData.set('nums[0]', 'num3');
formData.append('keep.going[]', 'go.');
formData.append('keep.going[]', 'go..');
formData.append('keep.going[]', 'go...');
formData.set('keep.so', 'what');
formData.append('well', 'wow');
formData.append('well', 'wow');
formData.append('well', 'wow');
console.log(formDataToObject(formData));
/*
{
foo: 'foo',
num1: '1',
num2: '2',
num3: '3',
bar: {
foo: 'FOO',
bar: 'BAR',
baz: 'BAZ',
},
baz: ['baz1', 'baz2', 'baz3'],
nums: ['num3', 'num2', 'num1'],
keep: {
going: ['go.', 'go..', 'go...'],
so: 'what'
},
well: ['wow', 'wow', 'wow']
}
*/
import { z, type ZodEffects, type ZodObject, type ZodRawShape, type ZodTypeAny } from 'zod';
export type Equals<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false;
type NonUndefined<T> = Exclude<T, undefined>;
/**
* The type of schema and validation 1:1 matching
*/
export type ZodInferSchema<T extends object> = {
[Key in keyof T]-?: Equals<T[Key], NonUndefined<T[Key]>> extends false
?
| z.ZodOptional<z.ZodType<NonNullable<T[Key]>>>
| z.ZodPipeline<z.ZodOptional<z.ZodType>, z.ZodType<T[Key]>>
| z.ZodEffects<z.ZodType<T[Key]>, T[Key], any>
: z.ZodType<T[Key]> | z.ZodPipeline<z.ZodType, z.ZodType<T[Key]>> | z.ZodEffects<z.ZodType<T[Key]>, T[Key], any>;
};
/**
* Preprocessing schema for accepting strings entered in `<input>` as other types.
* Unlike `z.coerce`, this is useful for handling optional processing that ignores empty strings.
* @param {Object} schema - The target schema.
* @example Schema for a number input like `<input type="number" />`
* ```js
* const inputNumber = zodInputType(z.coerce.number().optional());
* ```
*/
export const zodInputType = <T extends ZodTypeAny>(schema: T) => z.preprocess((val) => val || undefined, schema);
/**
* Validates form data against a Zod schema, supporting nested objects and arrays
* @param schema - Zod schema for validation
* @param data - FormData object
* @returns Zod parsing result
*/
export function validateFormData<T extends ZodRawShape>(
schema: ZodObject<T> | ZodEffects<ZodObject<T>>,
data: FormData
) {
return schema.safeParse(formDataToObject(data));
}
/**
* Type helper to extract all possible form data paths from a type,
* excluding object and array intermediate paths
*/
type FormDataPaths<T> = T extends object
? {
[K in keyof T]: K extends string | number
? NonNullable<T[K]> extends any[]
? NonNullable<T[K]>[number] extends object
? `${K}[].${FormDataPaths<NonNullable<T[K]>[number]>}`
: `${K}[]` // Include primitive arrays
: NonNullable<T[K]> extends object
? `${K}.${FormDataPaths<NonNullable<T[K]>>}`
: `${K}` // Only include primitive fields directly
: never;
}[keyof T]
: never;
/**
* Creates a type-safe object with form-data compatible paths from a Zod schema.
* Only includes leaf field paths, excluding intermediate object or array keys.
* Properly handles ZodOptional and ZodEffects wrapped types.
*
* @param schema - A Zod object schema
* @returns An object with all form-data compatible leaf paths as keys
*
* @example
* const schema = z.object({
* username: z.string(),
* location: z.object({
* latitude: z.number(),
* longitude: z.number(),
* }).optional(),
* strings: z.array(z.object({ value: z.string() })),
* tags: z.array(z.string()),
* });
*
* const userPath = createFormDataPaths(schema);
* userPath.username; // 'username'
* userPath['location.latitude']; // 'location.latitude'
* userPath['strings[].value']; // 'strings[].value'
* userPath['tags[]']; // 'tags[]'
*/
export function createFormDataPaths<T extends z.ZodObject<any>>(schema: T) {
type SchemaType = z.infer<T>;
/**
* Unwraps ZodOptional, ZodEffects, ZodDefault, and other wrapper types to get the inner schema
*/
function unwrapSchema(schema: z.ZodTypeAny): z.ZodTypeAny {
if (schema instanceof z.ZodOptional) {
return unwrapSchema(schema._def.innerType);
}
if (schema instanceof z.ZodEffects) {
return unwrapSchema(schema._def.schema);
}
if (schema instanceof z.ZodDefault) {
return unwrapSchema(schema._def.innerType);
}
if (schema instanceof z.ZodNullable) {
return unwrapSchema(schema._def.innerType);
}
return schema;
}
/**
* Helper function to recursively extract paths from a Zod schema
*/
function extractPaths(originalSchema: z.ZodTypeAny, prefix: string = ''): Record<string, string> {
const result: Record<string, string> = {};
// Unwrap any wrapper types to get the actual schema type
const schema = unwrapSchema(originalSchema);
if (schema instanceof z.ZodObject) {
const shape = schema._def.shape();
for (const [key, fieldSchema] of Object.entries(shape)) {
const currentPath = prefix ? `${prefix}.${key}` : key;
// Unwrap the field's schema
const unwrappedField = unwrapSchema(fieldSchema as ZodTypeAny);
if (unwrappedField instanceof z.ZodObject) {
// For objects, process nested fields
const nestedPaths = extractPaths(fieldSchema as ZodTypeAny, currentPath);
Object.assign(result, nestedPaths);
} else if (unwrappedField instanceof z.ZodArray) {
// For arrays, check the element type
const unwrappedElementType = unwrapSchema(unwrappedField._def.type);
if (unwrappedElementType instanceof z.ZodObject) {
// For arrays of objects, process nested fields
const arrayPath = `${currentPath}[]`;
const nestedPaths = extractPaths(unwrappedElementType, arrayPath);
Object.assign(result, nestedPaths);
} else {
// For arrays of primitives
result[`${currentPath}[]`] = `${currentPath}[]`;
}
} else {
// For primitive fields
result[currentPath] = currentPath;
}
}
}
return result;
}
// Extract all paths
const paths = extractPaths(schema);
// Return with type safety
return paths as {
[K in FormDataPaths<SchemaType>]: K;
};
}
/**
* Converts FormData to a nested object structure
* @template Return - The expected return type
* @param formData - The FormData object to convert
* @returns A nested object representation of the FormData
* @throws Error if there's a structure conflict
*/
export function formDataToObject<Return = unknown>(formData: FormData): Return {
const result: Record<string, any> = {};
// Process each entry in the FormData
for (const [key, value] of formData.entries()) {
try {
processFormDataEntry(result, key, value);
} catch (error) {
if (error instanceof Error) {
throw new Error(`${error.message} (at key: ${key})`);
}
throw error;
}
}
return result as Return;
}
/**
* Process a single FormData entry and add it to the result object
* @param result - The result object being built
* @param key - The current key
* @param value - The value to set
* @throws Error if there's a structure conflict
* @private
*/
function processFormDataEntry(result: Record<string, any>, key: string, value: any): void {
// Check if this is an array notation
const arrayMatch = key.match(/^(.+?)(\[\d*\])$/);
if (arrayMatch) {
const baseName = arrayMatch[1];
const indexMatch = arrayMatch[2].match(/\[(\d*)\]/);
// Handle array
if (!(baseName in result)) {
result[baseName] = [];
} else if (!Array.isArray(result[baseName])) {
throw new Error(`Structure conflict: ${baseName} cannot be both an array and a non-array value`);
}
if (indexMatch && indexMatch[1] !== '') {
// Indexed array (e.g., nums[2])
const index = parseInt(indexMatch[1], 10);
result[baseName][index] = value;
} else {
// Empty bracket array (e.g., baz[])
result[baseName].push(value);
}
return;
}
// Check if this is a nested object notation
if (key.includes('.')) {
const [first, ...rest] = key.split('.');
const restKey = rest.join('.');
// Create or validate nested object
if (!(first in result)) {
result[first] = {};
} else if (typeof result[first] !== 'object' || Array.isArray(result[first])) {
throw new Error(`Structure conflict: ${first} cannot be both an object and a non-object value`);
}
// Process the rest of the path recursively
processFormDataEntry(result[first], restKey, value);
return;
}
// Simple key
if (!(key in result)) {
// New key
result[key] = value;
} else {
// Key already exists
if (Array.isArray(result[key])) {
// Append to existing array
result[key].push(value);
} else {
// Convert to array
result[key] = [result[key], value];
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment