Skip to main content
oRPC provides a first-class error handling system. Errors thrown by procedures are serialized and sent to the client as structured JSON, with full type information preserved end-to-end.

ORPCError

ORPCError is the core error class. Throw it from any handler or middleware:
import { ORPCError } from '@orpc/server'

const procedure = os.handler(async () => {
  throw new ORPCError('NOT_FOUND', {
    status: 404,
    message: 'The requested resource was not found',
  })
})
The first argument is a string error code. By convention, codes are SCREAMING_SNAKE_CASE. The second argument is an options object:
OptionTypeDescription
statusnumberHTTP status code (must be 4xx or 5xx)
messagestringHuman-readable error message
dataanyExtra structured data attached to the error
causeunknownThe underlying cause (not sent to the client)

Standard error codes

oRPC pre-defines common error codes that map to standard HTTP status codes:
CodeStatus
BAD_REQUEST400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
METHOD_NOT_SUPPORTED405
TIMEOUT408
CONFLICT409
PRECONDITION_FAILED412
PAYLOAD_TOO_LARGE413
UNSUPPORTED_MEDIA_TYPE415
UNPROCESSABLE_CONTENT422
TOO_MANY_REQUESTS429
CLIENT_CLOSED_REQUEST499
INTERNAL_SERVER_ERROR500
NOT_IMPLEMENTED501
BAD_GATEWAY502
SERVICE_UNAVAILABLE503
GATEWAY_TIMEOUT504

Typed errors with .errors()

Declare the errors a procedure can throw using .errors(). This provides typed error constructors inside the handler and propagates type information to the client and OpenAPI spec.
import { os } from '@orpc/server'
import * as z from 'zod'

const procedure = os
  .errors({
    NOT_FOUND: {
      status: 404,
      message: 'Planet not found',
    },
    FORBIDDEN: {
      status: 403,
      data: z.object({ requiredRole: z.string() }),
    },
  })
  .handler(async ({ errors }) => {
    // errors.NOT_FOUND() creates an ORPCError with status 404
    throw errors.NOT_FOUND()

    // errors.FORBIDDEN() expects structured data
    throw errors.FORBIDDEN({ data: { requiredRole: 'admin' } })
  })
The errors object in the handler is a typed map of constructors. Each constructor accepts an optional options object without the status field (since that is set in the error map).

Error maps on builders

You can define shared errors on a builder so they are available to all derived procedures:
const base = os.errors({
  UNAUTHORIZED: { status: 401 },
  RATE_LIMITED: { status: 429 },
})

const procedure = base
  .errors({ NOT_FOUND: { status: 404 } }) // merged with base errors
  .handler(async ({ errors }) => {
    throw errors.UNAUTHORIZED() // from base
    throw errors.NOT_FOUND()    // from this procedure
  })
Error maps are sparse-merged: the most specific definition wins.

isDefinedError()

On the client side, use isDefinedError() to narrow an unknown error to a typed ORPCError that was declared in the error map:
import { isDefinedError } from '@orpc/server'

try {
  await orpc.planet.find({ id: 99 })
} catch (error) {
  if (isDefinedError(error)) {
    // error is now typed as ORPCError with known code and data
    console.error(error.code, error.data)
  }
}

The safe() helper

For ergonomic error handling without try/catch:
import { safe } from '@orpc/server'

const [error, data] = await safe(orpc.planet.find({ id: 99 }))
if (error) {
  if (isDefinedError(error)) {
    console.error(error.code) // typed
  }
} else {
  console.log(data) // typed planet
}

Build docs developers (and LLMs) love