Last active
March 18, 2025 05:41
-
-
Save composite/538f1424e4bd234e8e668e2b0b4d7bd0 to your computer and use it in GitHub Desktop.
zod Utility: Between FormData and plain object!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'] | |
} | |
*/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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