@conform-to/zod
Version:
Conform helpers for integrating with Zod
434 lines (424 loc) • 15.9 kB
JavaScript
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 };