Skip to content

CSV upload fails for Zod 3 instruments with refined fields because getZodTypeName does not unwrap ZodEffects #1636

Open

Description

@joshunrau

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) }).

  1. 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 score column shows number.
  2. Upload a CSV with columns subjectID,date,score and the row subject1,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-schema safeParse that 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' (under Zod3 › generateSampleData). Rename it and assert that generateSampleData(getZodTypeName(z3.string().refine(Boolean))) returns 'string'.
  • 'should currently reject a refined number column, since getZodTypeName does not unwrap ZodEffects' (under processInstrumentCSV). Rename it and assert that the row resolves with score: 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

  1. added
    BugType: existing behavior is wrong
    Difficulty: LowIsolated change, about 2 hours or less
    Area: Webapps/web (clinician-facing React SPA)
    on Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: Webapps/web (clinician-facing React SPA)BugType: existing behavior is wrongDifficulty: LowIsolated change, about 2 hours or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions