Skip to content

createServerFn: type validator rejects serializable classes + ShallowErrorPlugin destroys custom Error properties at runtime #7339

@alexander-zuev

Description

@alexander-zuev

Two related issues when returning structured error objects from server functions.

1. Type: ValidateSerializableMapped rejects classes with toJSON()

createServerFn().handler(...) rejects return types that contain class instances, even when those classes implement toJSON() and are runtime-serializable.

The static serializability check (ValidateSerializableMapped) sees class methods and rejects the return type, even though the actual value would serialize to plain JSON through toJSON().

import { createServerFn } from '@tanstack/react-start'

class AppError {
  readonly _tag = 'AppError'
  constructor(readonly message: string) {}
  toJSON() { return { _tag: this._tag, message: this.message } }
}

type RpcResult<T, E> = { status: 'ok'; value: T } | { status: 'error'; error: E }

// TS error: "Function may not be serializable"
export const fn = createServerFn().handler(
  async (): Promise<RpcResult<string, AppError>> => {
    return { status: 'error', error: new AppError('fail') }
  }
)

Is there an intended way to tell TanStack Start that a class with toJSON() is serializable? If not, would you consider supporting one of:

  • toJSON()-aware serializability typing
  • custom serializer registration
  • an escape hatch for "already serialized" return values

2. Runtime: ShallowErrorPlugin destroys custom properties on Error subclasses

When a server function returns an object containing an Error subclass (even nested inside a plain wrapper), seroval's ShallowErrorPlugin intercepts it and reconstructs as new Error(message) — all custom properties are lost.

class NotFoundError extends Error {
  readonly code = 'NOT_FOUND'
  readonly entity: string
  readonly retryable = false

  constructor(entity: string, id: string) {
    super(`${entity} "${id}" not found`)
    this.entity = entity
  }
}

export const fn = createServerFn().handler(async () => {
  // Return a plain wrapper with a structured error inside
  return {
    status: 'error',
    error: new NotFoundError('User', '123'),
  }
})

Server sends: { status: "error", error: { code: "NOT_FOUND", entity: "User", retryable: false, message: "User \"123\" not found" } }

Client receives: { status: "error", error: Error("User \"123\" not found") }code, entity, retryable all gone.

The plugin walks the full object tree and catches anything where instanceof Error === true:

// @tanstack/router-core ShallowErrorPlugin
test(value) { return value instanceof Error },
deserialize(node, ctx) { return new Error(ctx.deserialize(node.message)) }

This makes it impossible to pass structured Error subclasses across the server fn boundary without manually breaking the prototype chain first.

Current workaround

For types: mapped type that strips methods and maps unknown to JsonValue.
For runtime: spread error into a plain object to break instanceof Error.

function toServerFnRpc<T, E>(result: Result<T, E>): RpcResult<T, E> {
  const serialized = Result.serialize(result)
  if (serialized.status === 'error' && typeof serialized.error === 'object' && serialized.error !== null) {
    const { stack: _, ...plain } = serialized.error as Record<string, unknown>
    return { status: 'error', error: plain } as RpcResult<T, E>
  }
  return serialized as RpcResult<T, E>
}

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