Custom Errors (experimental)

zsa provides an experimental feature that allows you to shape and customize the error object returned by your server actions. This feature enables you to include additional information or modify the structure of the error object to better suit your application's needs.

This feature is experimental and we may change the API in the future.

Shaping Error Objects

To shape the error object, you can use the experimental_shapeError method when creating a server action or procedure. This method takes a callback function that receives the err (the original error object) and typedData (an object containing the parsed and raw input data) as arguments.

The callback function should return an object that represents the desired shape of the error.

const shapeErrorAction = createServerAction()
  .input(z.object({ number: z.number().refine((n) => n > 0) }))
  .experimental_shapeError(({ err, typedData }) => {
    function getKey(key: string, defaultValue: string) {
      return typeof err === "object" && key in err ? err[key] : defaultValue
    }

    return {
      code: getKey("code", "ERROR"), 
      message: getKey("message", "Something went wrong"), 
      myCustomProperty: true
    }
  })
  .handler(async ({ input }) => {
    return input.number
  })

const [, err] = await shapeErrorAction({ number: 0 })
//   ^? { code: string, message: string, myCustomProperty: true }

Make sure to not return any vulnerable data in your custom errors such as stack traces or sensitive information.

Typed Errors

You are probably going to define your shape error functions before Typescript will have access to the input/output schemas of your actions.

To get around this, you can use the typedData object that is passed to the shape error function. This object contains the parsed and raw input data of the action.

const shapeErrorAction = createServerAction()
  .input(z.object({ number: z.number().refine((n) => n > 0) }))
  .experimental_shapeError(({ err, typedData }) => {
    return {
      inputRaw: typedData.inputRaw, 
      inputParsed: typedData.inputParsed, 
      inputParseErrors: typedData.inputParseErrors, 
      outputParseErrors: typedData.outputParseErrors 
    }
  })
  .handler(async ({ input }) => {
    return input.number
  })

When using typedData, it must be returned as values in an object. So for example, you can't return typedData.inputRaw as the error, but you can return an object such as { values: typedData.inputRaw }.

Chaining Shaped Errors

Shaped errors can be chained together when using procedures. When shaping an error in a procedure, you have access to the ctx object from the previous chained error.

actions.ts
"use server"
import z from "zod"
import { createServerActionProcedure } from "zsa"

const procedureA = createServerActionProcedure()
  .experimental_shapeError(({ err, typedData }) => {
    return {
      isError: true
    }
  })
  .handler(() => {})

const procedureB = createServerActionProcedure(procedureA)
  .experimental_shapeError(({ err, typedData, ctx }) => {
    return {
      ...ctx, 
      addingOn: true
    }
  })
  .handler(() => {})

const action = procedureB.createServerAction()
  .experimental_shapeError(({ err, typedData, ctx }) => {
    return {
      ...ctx, 
      addingOnFromAction: true
    }
  })
  .handler(() => {})

const [data, err] = await action()
type ERROR = typeof err
//   ^? { isError: true; addingOn: true; addingOnFromAction: true } | null

Custom React Hook Form Example

Here's an example of how you can use the custom error shaping feature with React Hook Form:

actions.ts
"use server"
import z from "zod"
import { createServerAction, ZSAError } from "zsa"
export const publicAction = createServerActionProcedure()
  .experimental_shapeError(({ err, typedData }) => {
    if (err instanceof ZSAError) {
      const { code: type, inputParseErrors, message } = err
      return {
        message: err.message,
        code: err.code,
        rhfErrors: { 
          root: {
            type,
            message: message ?? inputParseErrors?.fieldErrors?.[0],
          },
          ...Object.fromEntries(
            Object.entries(inputParseErrors?.fieldErrors ?? {}).map(
              ([name, errors]) => [name, { message: errors?.[0] }]
            )
          ),
        },
        values: typedData.inputRaw, 
      }
    }
    return {
      message: "Something went wrong",
      code: "ERROR",
      values: typedData.inputRaw, 
    }
  })
  .handler(() => {})
  .createServerAction()
export const produceNewMessage = publicAction
  .input(
    z.object({
      name: z.string().min(5),
    })
  )
  .handler(async ({ input }) => {
    await new Promise((resolve) => setTimeout(resolve, 500))
    return "Hello, " + input.name
  })

In this example, we shape the error object to include rhfErrors and values properties, which can be used directly with React Hook Form.

react-hook-form-example.tsx
const { isPending, execute, data, error } = useServerAction(produceNewMessage)
const form = useForm<z.infer<typeof formSchema>>({
  resolver: zodResolver(formSchema),
  defaultValues: error?.values, 
  errors: error?.rhfErrors, 
})

By shaping the error object, you can easily integrate it with React Hook Form, providing default values and error messages based on the error object.

The custom error shaping feature in zsa provides flexibility in structuring error objects to meet your application's specific needs, making it easier to handle and display errors in your user interface.