TanStack / TanStack/router

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

Open
#7,339 1 comment 2 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
15.1k
Forks
1.9k
Avg merge
1d 20h
Merged PRs (30d)
143

Description

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by tracing ValidateSerializableMapped for the createServerFn return type and ShallowErrorPlugin in @tanstack/router-core for nested Error handling. Reproduce both examples to confirm the type rejection and lost properties. Done means an agreed serialization behavior that supports the reported class or Error cases without the listed workarounds, with tests covering both paths.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend-api-design
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.