UNPKG

@volverjs/form-vue

Version:

Vue 3 Forms with @volverjs/ui-vue

479 lines (428 loc) 17.2 kB
import { ZodError, } from 'zod/v3' import { $ZodError, safeParse as z4SafeParse, safeParseAsync as z4SafeParseAsync, formatError as z4FormatError, } from 'zod/v4/core' import type * as z3 from 'zod/v3' import type * as z4 from 'zod/v4/core' import type { FormSchema, InferSchema, VvZodError, ZodIssue } from './types' // Helper function to determine the type of a value function _getValueType(value: unknown) { if (Array.isArray(value)) { return 'array' } if (value === null) { return 'null' } return typeof value } // Helper function to check if a value matches a schema type function _isValueCompatibleWithSchema(value: unknown, subSchema: z4.JSONSchema.JSONSchema): boolean { const valueType = _getValueType(value) if (subSchema.type) { // Handle type as array (e.g. ["string", "null"]) if (Array.isArray(subSchema.type)) { return subSchema.type.some(t => t === valueType || (t === 'integer' && valueType === 'number' && Number.isInteger(value as number)), ) } return subSchema.type === valueType || (subSchema.type === 'integer' && valueType === 'number' && Number.isInteger(value as number)) } // If no type specified, assume compatibility return true } export const isZod3Object = (value: z3.ZodTypeAny): value is z3.ZodObject<any> => { return value._def.typeName === 'ZodObject' } export const isZod4Object = (value: z4.$ZodType): value is z4.$ZodObject<any> => { return value._zod.def.type === 'object' } export const isZod3Default = (value: z3.ZodTypeAny): value is z3.ZodDefault<any> => { return value._def.typeName === 'ZodDefault' } export const isZod4Default = (value: z4.$ZodType): value is z4.$ZodDefault<any> => { return value._zod.def.type === 'default' } export const isZod3Nullable = (value: z3.ZodTypeAny): value is z3.ZodNullable<any> => { return value._def.typeName === 'ZodNullable' } export const isZod4Nullable = (value: z4.$ZodType): value is z4.$ZodNullable<any> => { return value._zod.def.type === 'nullable' } export const isZod3Record = (value: z3.ZodTypeAny): value is z3.ZodRecord<any, any> => { return value._def.typeName === 'ZodRecord' } export const isZod4Record = (value: z4.$ZodType): value is z4.$ZodRecord<any, any> => { return value._zod.def.type === 'record' } export const isZod3Array = (value: z3.ZodTypeAny): value is z3.ZodArray<any> => { return value._def.typeName === 'ZodArray' } export const isZod4Array = (value: z4.$ZodType): value is z4.$ZodArray<any> => { return value._zod.def.type === 'array' } export const isZod3Effects = (value: z3.ZodTypeAny): value is z3.ZodEffects<any> => { return value._def.typeName === 'ZodEffects' } export const isZod3Optional = (value: z3.ZodTypeAny): value is z3.ZodOptional<any> => { return value._def.typeName === 'ZodOptional' } export const isZod4Optional = (value: z4.$ZodType): value is z4.$ZodOptional<any> => { return value._zod.def.type === 'optional' } // zod 4 replacements for ZodEffects export const isZod4Pipe = (value: z4.$ZodType): value is z4.$ZodPipe<any> => { return value._zod.def.type === 'pipe' } export const isZod4Transform = (value: z4.$ZodType): value is z4.$ZodTransform<any> => { return value._zod.def.type === 'transform' } function _loopOnZod3Effects<Type extends z3.ZodTypeAny>( schema: Type | z3.ZodEffects<Type> | z3.ZodEffects<z3.ZodEffects<Type>>, ) { let toReturn = schema while (isZod3Effects(toReturn)) { toReturn = toReturn.innerType() } return toReturn } function _loopOnZod4Pipe<Type extends z4.$ZodType>( schema: | Type | z4.$ZodPipe<Type> | z4.$ZodPipe<any, Type>, ) { let toReturn = schema while (isZod4Pipe(toReturn)) { if (isZod4Transform(toReturn._zod.def.out)) { toReturn = toReturn._zod.def.in } else { toReturn = toReturn._zod.def.out as Type } } return toReturn } // Helper function to get the inner type of a Zod schema export const getZod3SchemaInnerType = <Type extends z3.ZodTypeAny>( schema: | Type | z3.ZodEffects<Type> | z3.ZodEffects<z3.ZodEffects<Type>> | z3.ZodOptional<Type>, ) => { let toReturn = _loopOnZod3Effects(schema) if (isZod3Optional(toReturn)) { toReturn = toReturn._def.innerType } return toReturn } export const getZod4SchemaInnerType = <Type extends z4.$ZodType>( schema: | Type | z4.$ZodPipe<Type> | z4.$ZodPipe<any, Type> | z4.$ZodOptional<Type>, ) => { let toReturn = _loopOnZod4Pipe(schema) if (isZod4Optional(toReturn)) { toReturn = toReturn._zod.def.innerType } return toReturn } // Helper function to check if a Zod schema is optional export const isZod3SchemaOptional = <Type extends z3.ZodTypeAny>( schema: | Type | z3.ZodEffects<Type> | z3.ZodEffects<z3.ZodEffects<Type>> | z3.ZodOptional<Type>, ) => { const toReturn = _loopOnZod3Effects(schema) if (isZod3Optional(toReturn)) { return true } return false } export const isZod4SchemaOptional = <Type extends z4.$ZodType>( schema: | Type | z4.$ZodPipe<Type> | z4.$ZodPipe<any, Type> | z4.$ZodOptional<Type>, ) => { const toReturn = _loopOnZod4Pipe(schema) if (isZod4Optional(toReturn)) { return true } return false } // Helper to resolve anyOf/oneOf variants function _resolveVariantSchema( variants: readonly z4.JSONSchema.BaseSchema[], original: unknown | undefined, ): unknown { if (variants.length === 0) { return original ?? undefined } if (original !== undefined) { // First pass: find exact type match for (const subSchema of variants) { if (_isValueCompatibleWithSchema(original, subSchema as z4.JSONSchema.JSONSchema)) { return defaultObjectByJSONSchema(subSchema as z4.JSONSchema.JSONSchema, original) } } // Second pass: try first schema that doesn't explicitly conflict for (const subSchema of variants) { const subSchemaTyped = subSchema as z4.JSONSchema.JSONSchema if (!subSchemaTyped.type || subSchemaTyped.type === 'object') { return defaultObjectByJSONSchema(subSchemaTyped, original) } } } // Fallback to first schema return defaultObjectByJSONSchema(variants[0] as z4.JSONSchema.JSONSchema, original) } export function defaultObjectByJSONSchema(schema: z4.JSONSchema.JSONSchema, original?: unknown): unknown { // Handle const — fixed value if ('const' in schema) { return schema.const } // Handle anyOf if (schema.anyOf && Array.isArray(schema.anyOf)) { return _resolveVariantSchema(schema.anyOf, original) } // Handle oneOf (semantically similar to anyOf for defaults) if (schema.oneOf && Array.isArray(schema.oneOf)) { return _resolveVariantSchema(schema.oneOf, original) } // Handle allOf — merge all sub-schemas into one and process if (schema.allOf && Array.isArray(schema.allOf)) { const merged: z4.JSONSchema.JSONSchema = { ...schema, allOf: undefined } for (const subSchema of schema.allOf) { const sub = subSchema as z4.JSONSchema.JSONSchema if (sub.properties) { merged.properties = { ...merged.properties as Record<string, z4.JSONSchema.JSONSchema>, ...sub.properties as Record<string, z4.JSONSchema.JSONSchema> } } if (sub.type && !merged.type) { merged.type = sub.type } } return defaultObjectByJSONSchema(merged, original) } // Handle type as array (e.g. ["string", "null"]) — check null first if (Array.isArray(schema.type)) { if (original === null && schema.type.includes('null')) { return null } // Find the first non-null type and delegate const nonNullType = schema.type.find(t => t !== 'null') if (nonNullType) { return defaultObjectByJSONSchema({ ...schema, type: nonNullType } as z4.JSONSchema.JSONSchema, original) } return schema.default ?? null } // Early return for non-object types if (schema.type !== 'object' || !schema.properties) { switch (schema.type) { case 'string': return typeof original === 'string' ? original : schema.default case 'number': case 'integer': return typeof original === 'number' ? original : schema.default case 'boolean': return typeof original === 'boolean' ? original : schema.default case 'null': return original === null ? original : schema.default case 'array': if (Array.isArray(original)) { if (schema.items) { return original.map(item => defaultObjectByJSONSchema(schema.items as z4.JSONSchema.JSONSchema, item)) } return original } return schema.default default: return schema.default } } // Object handling const properties = schema.properties as Record<string, z4.JSONSchema.JSONSchema> const isOriginalObject = original && typeof original === 'object' && !Array.isArray(original) const originalObj = isOriginalObject ? original as Record<string, unknown> : undefined const toReturn: Record<string, unknown> = {} // Process schema properties first for (const key in properties) { const originalValue = originalObj?.[key] toReturn[key] = defaultObjectByJSONSchema(properties[key], originalValue) } // Handle additional properties after schema properties if (originalObj && schema.additionalProperties !== false) { const schemaKeys = new Set(Object.keys(properties)) for (const [key, value] of Object.entries(originalObj)) { if (!schemaKeys.has(key)) { toReturn[key] = value } } } return toReturn } export const isZod4Schema = (schema: z3.ZodTypeAny | z4.$ZodType): schema is z4.$ZodType => { if ('_zod' in schema) { return true } return false } /** * Adapter that abstracts the version-specific Zod internals (zod v3 vs v4) so * that the `defaultObjectBySchema` algorithm can be written once. */ interface ZodAdapter { getInnerType: (schema: any) => any isObject: (schema: any) => boolean hasUnknownKeys: (objectSchema: any) => boolean getShapeEntries: (objectSchema: any) => [string, any][] isOptional: (schema: any) => boolean isDefault: (schema: any) => boolean getDefaultValue: (schema: any) => unknown getDefaultInnerType: (schema: any) => any isNullable: (schema: any) => boolean safeParse: (schema: any, value: unknown) => { success: boolean, data?: unknown } isArray: (schema: any) => boolean getArrayElement: (schema: any) => any isRecord: (schema: any) => boolean getRecordValue: (schema: any) => any } const zod3Adapter: ZodAdapter = { getInnerType: getZod3SchemaInnerType, isObject: isZod3Object, hasUnknownKeys: schema => schema._def.unknownKeys === 'passthrough', getShapeEntries: schema => ('shape' in schema ? Object.entries(schema.shape) : []), isOptional: isZod3SchemaOptional, isDefault: isZod3Default, getDefaultValue: schema => schema._def.defaultValue(), getDefaultInnerType: schema => schema._def.innerType, isNullable: isZod3Nullable, safeParse: (schema, value) => schema.safeParse(value), isArray: isZod3Array, getArrayElement: schema => schema._def.type, isRecord: isZod3Record, getRecordValue: schema => schema._def.valueType, } const zod4Adapter: ZodAdapter = { getInnerType: getZod4SchemaInnerType, isObject: isZod4Object, hasUnknownKeys: schema => Boolean(schema._zod.def.catchall && schema._zod.def.catchall._zod.def.type !== 'never'), getShapeEntries: schema => ('shape' in schema._zod.def ? Object.entries(schema._zod.def.shape) : []), isOptional: isZod4SchemaOptional, isDefault: isZod4Default, getDefaultValue: schema => schema._zod.def.defaultValue, getDefaultInnerType: schema => schema._zod.def.innerType, isNullable: isZod4Nullable, safeParse: (schema, value) => z4SafeParse(schema, value), isArray: isZod4Array, getArrayElement: schema => schema._zod.def.element, isRecord: isZod4Record, getRecordValue: schema => schema._zod.def.valueType, } // Resolves the default value for an array shape entry, recursing into object elements. function _resolveArrayValue(adapter: ZodAdapter, innerType: any, originalValue: unknown[]): unknown { const arrayType = adapter.getInnerType(adapter.getArrayElement(innerType)) if (adapter.isObject(arrayType)) { return originalValue.map(element => _defaultObjectFromShape(adapter, arrayType, element)) } return originalValue } // Resolves the default value for a record shape entry, recursing into object values. function _resolveRecordValue(adapter: ZodAdapter, innerType: any, originalValue: object): unknown { const valueType = adapter.getInnerType(adapter.getRecordValue(innerType)) if (!adapter.isObject(valueType)) { return originalValue } return Object.keys(originalValue).reduce<Record<string, unknown>>((acc, recordKey) => { acc[recordKey] = _defaultObjectFromShape(adapter, valueType, (originalValue as Record<string, unknown>)[recordKey]) return acc }, {}) } // Resolves the default value for a single shape entry of a Zod object schema. function _resolveShapeEntry(adapter: ZodAdapter, key: string, subSchema: any, originalValue: unknown): [string, unknown] { const isOptional = adapter.isOptional(subSchema) let innerType = adapter.getInnerType(subSchema) let defaultValue: unknown if (adapter.isDefault(innerType)) { defaultValue = adapter.getDefaultValue(innerType) innerType = adapter.getDefaultInnerType(innerType) } if (originalValue === null && adapter.isNullable(innerType)) { return [key, originalValue] } if ((originalValue === undefined || originalValue === null) && isOptional) { return [key, defaultValue] } if (innerType && originalValue !== undefined) { const parse = adapter.safeParse(subSchema, originalValue) if (parse.success) { return [key, parse.data ?? defaultValue] } } if (adapter.isArray(innerType) && Array.isArray(originalValue)) { return [key, _resolveArrayValue(adapter, innerType, originalValue)] } if (adapter.isRecord(innerType) && originalValue) { return [key, _resolveRecordValue(adapter, innerType, originalValue as object)] } if (adapter.isObject(innerType)) { return [key, _defaultObjectFromShape( adapter, innerType, originalValue && typeof originalValue === 'object' ? originalValue : defaultValue, )] } return [key, defaultValue] } // Builds the default object for an (already unwrapped) Zod object schema. function _defaultObjectFromShape(adapter: ZodAdapter, objectSchema: any, original: unknown): Record<string, unknown> { const safeOriginal = (original && typeof original === 'object' && !Array.isArray(original)) ? original as Record<string, unknown> : {} return { ...(adapter.hasUnknownKeys(objectSchema) ? safeOriginal : {}), ...Object.fromEntries( adapter.getShapeEntries(objectSchema).map(([key, subSchema]) => _resolveShapeEntry(adapter, key, subSchema, safeOriginal[key]), ), ), } } export function defaultObjectBySchema<Schema extends FormSchema>(schema: Schema, original: Partial<InferSchema<Schema>> & Record<string, unknown> = {}): Partial<InferSchema<Schema>> { const adapter = isZod4Schema(schema) ? zod4Adapter : zod3Adapter const innerType = adapter.getInnerType(schema) if (!adapter.isObject(innerType)) { return original } return _defaultObjectFromShape(adapter, innerType, original) as Partial<InferSchema<Schema>> } export const safeParseAsync = <T extends FormSchema>(schema: T, data: any) => { if (isZod4Schema(schema)) { return z4SafeParseAsync(schema, data) } return schema.safeParseAsync(data) } export const formatError = <T extends FormSchema>(schema: T, error: VvZodError<T>) => { if (isZod4Schema(schema)) { return z4FormatError(error as z4.$ZodError<T>) } return (error as z3.ZodError<T>).format() } export const formatIssues = (schema: FormSchema, issues: ZodIssue[]) => { if (isZod4Schema(schema)) { return z4FormatError(new $ZodError(issues as z4.$ZodIssue[])) } return new ZodError(issues as z3.ZodIssue[]).format() }