Server

Error Handling

Return errors so the client gets them typed, and end the request when it must stop.

mion uses RpcError for every error in routes and middleware. How you raise it decides whether the client gets it typed and whether the rest of the chain runs.

What you writeTyped on the clientThe rest of the chain
return new RpcError(...)Yes, as the route's error or at the middleware's pathKeeps running
return new FatalError(...)Yes, as the route's error or at the middleware's pathStops
throw anythingNo, only the public message in @thrownErrorsStops

Return Errors

Return application errors. A returned error is part of the function signature, so the client knows every error type a route can answer, its errorData included. With several error types, the client gets a discriminated union of all of them. A returned error keeps the chain running, so one route answering user-not-found does not drop the other routes in the same request.

export const getPet = mion.route(
  async (ctx, id: string): Promise<Pet | RpcError<'pet-not-found'>> => {
    const pet = await myApp.db.getPet(id);
    if (!pet) {
      return new RpcError({
        publicMessage: `Pet with id ${id} can't be found`,
        type: 'pet-not-found',
      });
    }
    return pet;
  }
) satisfies Route;
A middleware can return a typed error without blocking, such as an expired session on a route that still works for guests.

Stopping the Request

Return a FatalError when an error must also stop the request, like a failed auth check in a middleware. It is an RpcError, so it stays in the signature and reaches the client typed. Nothing after it runs except the middleware with alwaysRun, and in a batch the routes after it are skipped.

// ends the request: the route behind this middleware never runs
export const auth = mion.middleware(
  (ctx): void | FatalError<'not-authorized'> => {
    if (!myApp.auth.getSessionUser(ctx.request.headers.get('cookie')))
      return new FatalError({
        publicMessage: 'Not Authorized',
        type: 'not-authorized',
      });
  }
);
The client sees a returned FatalError like any declared error: at the middleware's path and in its onError listeners, never in @thrownErrors. It is not told the route was skipped: a route that returns a value simply gives undefined.

HTTP Status Codes

A FatalError sets the response's HTTP status from its statusCode, such as 401. Without one it answers 400, a declared application error, like the auth gate above. A thrown error without a statusCode answers 422, something the server did not expect.

Identifying Error Types

The client gets returned errors fully typed. Tell them apart by error.type, never by the HTTP status code, which is only a transport detail in an RPC API.

import {isRpcError} from '@mionjs/core';
import {initClient} from '@mionjs/client';
import type {MyApi} from './auth-user.routes.ts';

const {routes} = initClient<MyApi>({
  baseURL: 'http://localhost:3000',
});

const [user, error, response] = await routes.users.getById('USER-404').call();
const thrown = response['@thrownErrors'];

// error.type is the discriminator, never the HTTP status code
if (error) {
  switch (error.type) {
    case 'user-not-found':
      // declared payloads survive narrowing: errorData is UserNotFoundData
      console.log('missing user:', error.errorData?.requestedId);
      break;
    case 'validation-error':
      console.log('type errors:', error.errorData?.typeErrors.length);
      break;
  }
} else if (isRpcError(response.auth)) {
  // the middleware's declared error; its onError hook gets it strongly typed
  console.log('auth failed:', response.auth.type);
} else if (thrown) {
  // transport, platform or any undeclared error lands here
  console.log('request failed:', thrown[0].type);
} else {
  console.log(user?.name); // John
}

// typeErrors() is the only method that can throw
try {
  const errors = await routes.users.getById(null as any).typeErrors();
  console.log(errors); // [] (empty array if no errors)
} catch (validationError: any) {
  // { type: 'validation-error', message: `Invalid params ...` }
  console.log(validationError);
}

Throwing Errors (Discouraged)

Throw only for truly unexpected situations, like bugs or infrastructure failures. A thrown error ends the request like a FatalError, but it is not in the function signature, so the client gets only its publicMessage, in @thrownErrors.

export const updatePet = mion.route(async (ctx, pet: Pet): Promise<Pet> => {
  try {
    return await myApp.db.updatePet(pet);
  } catch (dbError) {
    // a logger still reads the full error on ctx.request.thrownErrors and ctx.response.fatalError
    throw new RpcError({
      publicMessage: `Cant update the pet.`,
      message: (dbError as Error).message,
      originalError: dbError as Error,
      type: 'db-error',
    });
  }
}) satisfies Route;

export const alwaysError = mion.route((): void => {
  throw new Error('the client gets a 422 unknown-error, never this message');
}) satisfies Route;

The router itself throws for what nobody declared: a missing route, an unreadable body, a payload over the limit. The exception is validation-error: it is thrown on the server but is part of every route's contract, so the client still gets it typed.

The linter reports a throw inside a handler as rpc-handler-throws. To keep a throw, add // @mion-expect-error rpc-handler-throws above it, and a comment saying why.

Error Response Format

  • Returned errors (strongly typed), a FatalError included, appear under the route or middleware id with their type information.
  • Thrown errors (untyped) go in the @thrownErrors field.
{
    "getUser": [0, {
        "type": "user-not-found",
        "publicMessage": "User not found",
        "errorData": {"userId": "123"}
    }],
    "@thrownErrors": {
        "someMiddleware": {
            "type": "unknown-error",
            "publicMessage": "Unknown error in handler \"someMiddleware\" of route ExecutionChain.",
            "statusCode": 422
        }
    }
}

getUser returns User | RpcError, so its value is sent as an [index, value] pair.

The mion client handles this for you: read the error from the result. Only public fields are sent: the internal message, the fatal flag and the stack stay on the server.

Always Run Middleware

A middleware with alwaysRun: true runs even after an error ended the request, see Always Run.

Copyright © 2026