Skip to content

inputValidator validation errors lose structure during serialization - issues array becomes JSON string in error.message #6428

Open

Description

@alexander-zuev

Describe the issue

Not a big but rather a feedback / issue I'd like to raise

When inputValidator fails (e.g., with Zod), the validation error is serialized in a way that loses all structure. The Zod issues array is JSON-stringified and stuffed into error.message, making it unusable on the client without manual parsing.

Your minimal, reproducible example

// Server function
export const myFn = createServerFn({ method: 'POST' })
  .inputValidator(z.object({
    name: z.string().min(2, 'Name must be at least 2 characters'),
    age: z.number().min(18, 'Must be 18+'),
  }))
  .handler(async ({ data }) => data)

// Client - send invalid data
mutation.mutate({ data: { name: 'A', age: 15 } })

Current behavior

Client receives:

{
  name: "Error",  // Lost ZodError type
  message: '[{"origin":"string","code":"too_small","path":["name"],"message":"Name must be at least 2 characters"},...]',
  // ↑ JSON string, not structured data
  // No .issues, no .code - just raw JSON crammed into message
}

Expected behavior

Client should receive structured error data:

{
  code: 'VALIDATION_ERROR',
  message: 'Validation failed',
  issues: [
    { path: ['name'], message: 'Name must be at least 2 characters' },
    { path: ['age'], message: 'Must be 18+' },
  ]
}

Or at minimum, preserve the error structure so error.issues is accessible.

Why this matters

  1. Can't distinguish error types - Is this validation error or server crash? Both are just Error
  2. Can't show field-level errors - Must JSON.parse(error.message) to extract issues
  3. Breaks standard patterns - HTTP APIs return 422 with structured validation errors; this pattern makes that impossible

How other frameworks handle this

  • tRPC: Preserves Zod error structure via superjson
  • Remix: Actions return structured data, not thrown errors
  • Next.js Server Actions: Can return error objects with structure

Suggested solutions

Either:

  1. Serialize known error types (ZodError) with their structure preserved
  2. Return validation failures as data (like HTTP 422 response body) instead of thrown errors
  3. Allow custom error serializers via configuration

Additional context

Related discussion: #2935 - covers broader error typing issues from server functions

Platform

  • @tanstack/react-start: 1.153.1
  • Zod: 4.x
  • Runtime: Cloudflare Workers

Activity

  1. alexander-zuev commented on Jan 20, 2026

    @alexander-zuev
    ContributorAuthor

    Additional finding: Function middleware types prevent custom error responses

    While investigating workarounds, I discovered a fundamental type constraint in createMiddleware({ type: 'function' }).

    The Problem

    Function middleware's .server() handler is typed to ONLY return next() result. Looking at createMiddleware.d.ts line 92-100:

    export type FunctionServerResultWithContext<...> = {
      'use functions must return the result of next()': true;  // <-- Literal marker enforcing next()
      '~types': {...};
      context: ...;
      sendContext: ...;
    };

    This means middleware cannot return custom error responses like:

    // This works at RUNTIME but fails TypeScript
    export const errorHandler = createMiddleware({ type: 'function' }).server(
      async ({ next }) => {
        try {
          return await next()
        } catch (error) {
          setResponseStatus(422)
          return { success: false, error: { code: 'VALIDATION_ERROR', issues } }  // TS Error!
        }
      },
    )

    Contrast with Request Middleware

    Request middleware (type: 'request') CAN return Response (line 182):

    export type RequestMiddlewareServerFnResult<...> = 
      Promise<RequestServerResult<...> | Response> | Response;  // Response allowed!

    Current Workaround

    Using as never type assertion (cleaner than as any):

    return { success: false, error: {...} } as never

    Runtime behavior is correct - TanStack serializes the custom response to the client. But the types don't support this valid use case.

    Suggestion

    Consider allowing function middleware to return custom responses for error handling, similar to how request middleware can return Response. Error handling middleware is a common pattern that the current types don't accommodate.

  2. JClackett commented on Apr 2, 2026

    @JClackett

    YESSS! ive just run into this, and its super unfortunate that the inputValidator replaces the ZodError.

    if it kept the ZodError class, we could build a custom serialiser that parses the zod issues on the frontend so we can display nice messages, but now that its just an Error class theres no meaningful way of doing this.

    The only way now is to wrap every inputValidator scheme with a custom function that rethrows with a custom error class.

    It's this line here:

    throw new Error(JSON.stringify(result.issues, undefined, 2))

    which I understand because its within the standard schema branch, and its cool that it serialises the issues, but if we can't tag it as a validation parse error, what would be the most type-safe/reliable way of knowing its a validation issue?

    Maybe start could throw a custom "standard schema" class or something, so that we can at least check its instanceof at least?

    You could then create a serializationAdapter that checks for this and then use that on the client to get structured validation errors.

    // would just need to be something like this? Issue from standard schema
    export class SomeCustomErrorClass <T extends ReadonlyArray<Issue>[]> extends Error {
      public readonly issues: Issue[]
      constructor(issues: T) {
        this.issues = issues
        // etc etc
      }
    }
    
    // then a custom serialiser
    export const serializedZodIssuesAdapter = createSerializationAdapter({
      key: "serializedZodIssues",
      test: (value: unknown) => {
        return value instanceof SomeCustomErrorClass
      },
      toSerializable: (value) => JSON.stringify(value.issues),
      fromSerializable: (value) => {
        const issues = JSON.parse(value) as $ZodIssue[]
        return new SomeCustomErrorClass(issues)
      },
    })
    
    // in your ui you could then render the issues
    function FormFieldErrors({ error }: { error: Error | null }) {
      if (!error || !(error instanceof SomeCustomErrorClass)) return null
      if (error.issues.length === 0) return null
      return (
        <ul className="list-disc pl-4 text-muted-foreground">
          {error.issues.map((issue) => (
            <li key={issue.path.join(".")}>
              {issue.message} for {issue.path.join(".")}
            </li>
          ))}
        </ul>
      )
    }
  3. JClackett commented on Apr 2, 2026

    @JClackett

    For now im just wrapping every zod schema with a custom parse zod function:

    export function parseZod<Schema extends z.ZodSchema>(schema: Schema): (input: z.input<Schema>) => z.output<Schema> {
      return (input: z.input<Schema>) => {
        const res = schema.safeParse(input)
        if (res.success) return res.data
        throw new SerializedZodIssues(res.error.issues)
      }
    }

    Works well, but just a bit annoying having to remember to use this everytime

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions