createServerFn: type validator rejects serializable classes + ShallowErrorPlugin destroys custom Error properties at runtime
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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