UNPKG

@conform-to/zod

Version:

Conform helpers for integrating with Zod

434 lines (424 loc) 15.9 kB
import { objectSpread2 as _objectSpread2 } from '../_virtual/_rollupPluginBabelHelpers.mjs'; import { any, ZodArray, ZodObject, ZodEffects, ZodOptional, ZodDefault, ZodCatch, ZodIntersection, ZodUnion, ZodDiscriminatedUnion, ZodBranded, ZodTuple, ZodNullable, ZodPipeline, lazy } from 'zod/v3'; function defaultConvertNumber(text) { return text.trim() === '' ? NaN : Number(text); } function defaultConvertBoolean(text) { if (text === 'on') { return true; } throw new Error('Not a boolean'); } function defaultConvertDate(text) { var date = new Date(text); if (isNaN(date.getTime())) { throw new Error('Invalid date'); } return date; } function defaultConvertBigInt(text) { if (text.trim() === '') { throw new Error('Empty bigint'); } return BigInt(text); } function coerceString(value, fn) { if (typeof value !== 'string') { return value; } return fn(value); } function coerceFile(file) { if (typeof File !== 'undefined' && file instanceof File && file.name === '' && file.size === 0) { return undefined; } return file; } /** * strip → undefined stops, else convert, catch → original string */ function createValidationCoercion(strip, convert) { return value => coerceString(value, text => { var stripped = strip(text); if (stripped === undefined) { return undefined; } try { return convert(stripped); } catch (_unused) { return stripped; } }); } /** * On error returns the sentinel (e.g. NaN, false) so typeCheckTransform accepts it. */ function createStructuralCoercion(convert, emptySentinel) { return value => coerceString(value, text => { try { return convert(text); } catch (_unused2) { return emptySentinel; } }); } /** * A file schema is usually defined with `z.instanceof(File)` * which is implemented with `z.custom()` based on ZodAny with a `superRefine` check * See https://github.com/colinhacks/zod/blob/eea05ae3dab628e7a834397414e5145e935e418b/src/types.ts#L5250-L5285 */ function isFileSchema(schema) { if (typeof File === 'undefined') { return false; } return schema._def.effect.type === 'refinement' && schema.innerType()._def.typeName === 'ZodAny' && schema.safeParse(new File([], '')).success && !schema.safeParse('').success; } /** * Constrained types (enum, literal, nativeEnum) should still be validated in * structural mode because their values come from constrained UI (select, radio, * hidden input) and an invalid value indicates a developer error. */ function isConstrainedType(type) { var def = type._def; return def.typeName === 'ZodEnum' || def.typeName === 'ZodLiteral' || def.typeName === 'ZodNativeEnum'; } /** * Accepts sentinel values like NaN and Invalid Date since * `typeof NaN === 'number'` and `Invalid Date instanceof Date`. */ function createTypeCheckTransform(expectedType) { var check = expectedType === 'date' ? value => value instanceof Date : value => typeof value === expectedType; return any().superRefine((value, ctx) => { if (!check(value)) { ctx.addIssue({ code: 'invalid_type', expected: expectedType, received: typeof value, message: "Invalid input: expected ".concat(expectedType, ", received ").concat(typeof value) }); } }); } function getTypeCheckTarget(type) { var def = type._def; switch (def.typeName) { case 'ZodString': return createTypeCheckTransform('string'); case 'ZodNumber': return createTypeCheckTransform('number'); case 'ZodBoolean': return createTypeCheckTransform('boolean'); case 'ZodDate': return createTypeCheckTransform('date'); case 'ZodBigInt': return createTypeCheckTransform('bigint'); default: return type; } } function selectCoercion(type, defaultCoercion) { var def = type._def; if (def.typeName === 'ZodString' || def.typeName === 'ZodEnum' || def.typeName === 'ZodNativeEnum') { return defaultCoercion.string; } else if (def.typeName === 'ZodLiteral') { var literalValue = type.value; if (typeof literalValue === 'number') { return defaultCoercion.number; } if (typeof literalValue === 'boolean') { return defaultCoercion.boolean; } if (typeof literalValue === 'bigint') { return defaultCoercion.bigint; } return defaultCoercion.string; } else if (def.typeName === 'ZodEffects' && isFileSchema(type)) { return defaultCoercion.file; } else if (def.typeName === 'ZodNumber') { return defaultCoercion.number; } else if (def.typeName === 'ZodBoolean') { return defaultCoercion.boolean; } else if (def.typeName === 'ZodDate') { return defaultCoercion.date; } else if (def.typeName === 'ZodBigInt') { return defaultCoercion.bigint; } } /** * Reconstruct the provided schema with additional preprocessing steps * This strips empty values to undefined and coerces string to the correct type */ function coerceType(type, options) { var result = options.resolved.get(type); if (result) { // Prevent infinite recursion from z.lazy() return result; } var schema = type; var def = type._def; var coercion = options.coerce(type); var target = options.skipValidation && !isConstrainedType(type) ? getTypeCheckTarget(type) : type; if (coercion) { schema = any().transform(coercion).pipe(target); } else if (target !== type) { schema = target; } else if (def.typeName === 'ZodArray') { var arrayDef = options.skipValidation ? _objectSpread2(_objectSpread2({}, def), {}, { minLength: null, maxLength: null, exactLength: null }) : def; schema = any().transform(value => { if (Array.isArray(value)) { return value; } if (typeof value === 'undefined' || options.stripEmptyValue && typeof options.stripEmptyValue(value) === 'undefined') { return []; } return [value]; }).pipe(new ZodArray(_objectSpread2(_objectSpread2({}, arrayDef), {}, { type: coerceType(def.type, options) }))); } else if (def.typeName === 'ZodObject') { var objectDef = options.skipValidation ? _objectSpread2(_objectSpread2({}, def), {}, { unknownKeys: 'strip' }) : def; schema = any().transform(value => { if (typeof value === 'undefined') { return {}; } return value; }).pipe(new ZodObject(_objectSpread2(_objectSpread2({}, objectDef), {}, { shape: () => Object.fromEntries(Object.entries(def.shape()).map(_ref => { var [key, def] = _ref; return [key, // @ts-expect-error ZodTypeAny vs shape value type mismatch coerceType(def, options)]; })) }))); } else if (def.typeName === 'ZodEffects') { if (options.skipTransforms && (def.effect.type === 'transform' || def.effect.type === 'preprocess') || options.skipValidation && def.effect.type === 'refinement') { schema = coerceType(def.schema, options); } else { schema = new ZodEffects(_objectSpread2(_objectSpread2({}, def), {}, { schema: coerceType(def.schema, options) })); } } else if (def.typeName === 'ZodOptional') { var innerType = coerceType(def.innerType, options); var wrapped = new ZodOptional(_objectSpread2(_objectSpread2({}, def), {}, { innerType })); schema = options.stripEmptyValue ? any().transform(options.stripEmptyValue).pipe(wrapped) : wrapped; } else if (def.typeName === 'ZodDefault') { if (options.skipDefaults) { schema = coerceType(def.innerType, options).optional(); } else { var defaultValue = def.defaultValue(); var _innerType = defaultValue !== '' // Don't strip the empty string that IS the default ? coerceType(def.innerType, options) : def.innerType; var _wrapped = new ZodDefault(_objectSpread2(_objectSpread2({}, def), {}, { innerType: _innerType })); schema = options.stripEmptyValue ? any().transform(options.stripEmptyValue).pipe(_wrapped) : _wrapped; } } else if (def.typeName === 'ZodCatch') { schema = new ZodCatch(_objectSpread2(_objectSpread2({}, def), {}, { innerType: coerceType(def.innerType, options) })); } else if (def.typeName === 'ZodIntersection') { schema = new ZodIntersection(_objectSpread2(_objectSpread2({}, def), {}, { left: coerceType(def.left, options), right: coerceType(def.right, options) })); } else if (def.typeName === 'ZodUnion') { schema = new ZodUnion(_objectSpread2(_objectSpread2({}, def), {}, { options: def.options.map(option => coerceType(option, options)) })); } else if (def.typeName === 'ZodDiscriminatedUnion') { schema = any().transform(value => { if (typeof value === 'undefined') { return {}; } return value; }).pipe(new ZodDiscriminatedUnion(_objectSpread2(_objectSpread2({}, def), {}, { options: def.options.map(option => coerceType(option, options)), optionsMap: new Map(Array.from(def.optionsMap.entries()).map(_ref2 => { var [discriminator, option] = _ref2; return [discriminator, coerceType(option, options)]; })) }))); } else if (def.typeName === 'ZodBranded') { schema = new ZodBranded(_objectSpread2(_objectSpread2({}, def), {}, { type: coerceType(def.type, options) })); } else if (def.typeName === 'ZodTuple') { schema = new ZodTuple(_objectSpread2(_objectSpread2({}, def), {}, { items: def.items.map(item => coerceType(item, options)) })); } else if (def.typeName === 'ZodNullable') { schema = new ZodNullable(_objectSpread2(_objectSpread2({}, def), {}, { innerType: coerceType(def.innerType, options) })); } else if (def.typeName === 'ZodPipeline') { if (options.skipTransforms) { schema = coerceType(def.in, options); } else { schema = new ZodPipeline(_objectSpread2(_objectSpread2({}, def), {}, { in: coerceType(def.in, options), out: coerceType(def.out, options) })); } } else if (def.typeName === 'ZodLazy') { var inner = def.getter(); schema = lazy(() => coerceType(inner, options)); } if (type !== schema) { options.resolved.set(type, schema); } return schema; } /** * Creates configured coercion functions for form value parsing. * * **Example:** * * ```tsx * import { configureCoercion } from '@conform-to/zod/v3/future'; * import { z } from 'zod'; * * const { coerceFormValue, coerceStructure } = configureCoercion({ * // Trim whitespace and treat whitespace-only as empty * stripEmptyString: (value) => { * const trimmed = value.trim(); * return trimmed === '' ? undefined : trimmed; * }, * type: { * // Strip commas from numbers like "1,000" before converting * number: (text) => Number(text.replace(/,/g, '')), * }, * }); * * const schema = z.object({ age: z.number(), name: z.string() }); * const validationSchema = coerceFormValue(schema); * const structuralSchema = coerceStructure(schema); * ``` */ function configureCoercion(config) { var _config$stripEmptyStr, _config$type$number, _config$type, _config$type$boolean, _config$type2, _config$type$date, _config$type3; var stripEmptyString = (_config$stripEmptyStr = config === null || config === void 0 ? void 0 : config.stripEmptyString) !== null && _config$stripEmptyStr !== void 0 ? _config$stripEmptyStr : value => value === '' ? undefined : value; var convertNumber = (_config$type$number = config === null || config === void 0 || (_config$type = config.type) === null || _config$type === void 0 ? void 0 : _config$type.number) !== null && _config$type$number !== void 0 ? _config$type$number : defaultConvertNumber; var convertBoolean = (_config$type$boolean = config === null || config === void 0 || (_config$type2 = config.type) === null || _config$type2 === void 0 ? void 0 : _config$type2.boolean) !== null && _config$type$boolean !== void 0 ? _config$type$boolean : defaultConvertBoolean; var convertDate = (_config$type$date = config === null || config === void 0 || (_config$type3 = config.type) === null || _config$type3 === void 0 ? void 0 : _config$type3.date) !== null && _config$type$date !== void 0 ? _config$type$date : defaultConvertDate; var validationMap = { string: value => coerceString(value, stripEmptyString), file: coerceFile, number: createValidationCoercion(stripEmptyString, convertNumber), boolean: createValidationCoercion(stripEmptyString, convertBoolean), date: createValidationCoercion(stripEmptyString, convertDate), bigint: createValidationCoercion(stripEmptyString, defaultConvertBigInt) }; var structuralMap = { number: createStructuralCoercion(convertNumber, NaN), boolean: createStructuralCoercion(convertBoolean, false), date: createStructuralCoercion(convertDate, new Date('')), bigint: createStructuralCoercion(defaultConvertBigInt, 0n) }; var coerce = (type, coercionMap) => { var _selectCoercion; if (config !== null && config !== void 0 && config.customize) { var customFn = config.customize(type); if (customFn !== null) { return customFn; } } return (_selectCoercion = selectCoercion(type, coercionMap)) !== null && _selectCoercion !== void 0 ? _selectCoercion : null; }; var validationCache = new WeakMap(); var structureCache = new WeakMap(); return { /** * Enhances a schema to coerce form values and strip empty values before validation. * This configured helper uses the options passed to `configureCoercion`. * * Results are cached per schema, so this can be called inline. * * **Example:** * * ```tsx * const schema = coerceFormValue(z.object({ * age: z.number().optional(), * subscribe: z.boolean(), * })); * * schema.parse({ age: '', subscribe: 'on' }); * // { age: undefined, subscribe: true } * ``` */ coerceFormValue(type) { var result = validationCache.get(type); if (!result) { result = coerceType(type, { resolved: new Map(), stripEmptyValue(value) { return coerceFile(coerceString(value, stripEmptyString)); }, coerce(type) { return coerce(type, validationMap); } }); validationCache.set(type, result); } return result; }, /** * Enhances a schema to coerce form values without running validation. * This configured helper is useful for reading current form values as typed data. * * It skips validation, defaults, transforms, and refinements, and does not strip * empty strings to `undefined`. * * For number, boolean, date, and bigint schemas, empty strings and other failed * string coercions still become fallback values: * * - `z.number()` -> `NaN` * - `z.boolean()` -> `false` * - `z.date()` -> `Invalid Date` * - `z.bigint()` -> `0n` * * Results are cached per schema, so this can be called inline. * * **Example:** * * ```tsx * const schema = coerceStructure(z.object({ * age: z.number().min(10), * })); * * schema.parse({ age: '3' }); * // { age: 3 } * ``` */ coerceStructure(type) { var result = structureCache.get(type); if (!result) { result = coerceType(type, { resolved: new Map(), coerce(type) { return coerce(type, structuralMap); }, skipValidation: true, skipDefaults: true, skipTransforms: true }); structureCache.set(type, result); } return result; } }; } var { coerceFormValue, coerceStructure } = configureCoercion(); export { coerceFormValue, coerceStructure, configureCoercion };