Repository navigation
CSV upload fails for Zod 3 instruments with refined fields because getZodTypeName does not unwrap ZodEffects #1636
Description
CSV upload does not work for a Zod 3 form instrument that has a refined field, such as z.number().refine(...) or z.string().superRefine(...). Zod3.getZodTypeName unwraps ZodOptional but not ZodEffects. ZodEffects is in the accepted list, so the def falls through to the final return and its type name is cast to RequiredZodTypeName, a type that explicitly excludes 'ZodEffects'. Every consumer then hits its default branch. "Download template" fails with "Invalid zod schema: unexpected type name 'ZodEffects'", and uploading a CSV fails with "Unexpected Zod type name 'ZodEffects' at column name: '' …". So the upload page cannot be used at all for such an instrument. Object-level refinements are already handled. Only field-level ones are affected, and only on the Zod 3 path: in Zod 4, .refine() does not wrap the schema.
Where
apps/web/src/utils/upload.ts:287-322 (Zod3.getZodTypeName). The fall-through return at line 320 casts the name without checking it:
return {
isOptional: Boolean(isOptional),
typeName: def.typeName satisfies ZodTypeName as RequiredZodTypeName
};The resulting 'ZodEffects' then reaches the default branches of Zod3.generateSampleData (upload.ts:400-404) and Zod3.interpretZodConvertResult (upload.ts:446).
Reproduce
Use a Zod 3 form instrument with validationSchema: z.object({ score: z.number().refine((n) => n >= 0) }).
- Open the instrument on the upload page and click to download the template.
- Actual: error "Invalid zod schema: unexpected type name 'ZodEffects'".
- Expected: a template whose
scorecolumn showsnumber.
- Upload a CSV with columns
subjectID,date,scoreand the rowsubject1,2024-01-01,1.- Actual: "Unexpected Zod type name 'ZodEffects' at column name: 'score' and row number '1'".
- Expected: the row is parsed as
{ score: 1, … }, and the refinement is still enforced by the whole-schemasafeParsethat follows.
Tests
In apps/web/src/utils/__tests__/upload.test.ts, two tests pin the current behaviour and both need inverting when fixing:
'should currently reject a refined field, since getZodTypeName does not unwrap ZodEffects'(underZod3›generateSampleData). Rename it and assert thatgenerateSampleData(getZodTypeName(z3.string().refine(Boolean)))returns'string'.'should currently reject a refined number column, since getZodTypeName does not unwrap ZodEffects'(underprocessInstrumentCSV). Rename it and assert that the row resolves withscore: 1.
Suggested fix
In getZodTypeName, add an isZodEffectsDef(def) branch that returns getZodTypeName(def.schema, isOptional), just as the optional branch recurses into innerType. Carrying isOptional through keeps .optional().refine(...) working.
Found while raising unit test coverage in #1632.
Activity