Client

Error Handling

Handle errors in the mion client with the Result pattern.

Calls never throw. An error comes back in the result, next to the value, and TypeScript already knows which errors a route can return.

client.ts
import {initClient} from '@mionjs/client';
import type {MyApi} from './error-basic.routes.ts';

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

// call() never throws, errors come back in the result
const [user, error] = await routes.users.getById('123').call();

if (error?.type === 'user-not-found') {
  // the route's own error, narrowed by its type
  console.log('no user with that id');
} else if (error) {
  // the only other typed error: the params failed validation
  console.log('invalid params:', error.publicMessage);
} else if (user) {
  console.log('User:', user.name);
}
server.routes.ts
import {RpcError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';

const mion = createMionRouter();

export type User = {id: string; name: string};

// the returned error is part of the signature, so the client knows it exists
const routes = {
  users: {
    getById: mion.route(
      (ctx, id: string): User | RpcError<'user-not-found'> => {
        if (id !== '123')
          return new RpcError({
            publicMessage: `User ${id} not found`,
            type: 'user-not-found',
          });
        return {id, name: 'John'};
      }
    ),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;

The Result Pattern

call() returns [result, error, response]. The result and the error come only from the route and are strongly typed: its return value, one of its declared errors or a validation error. Every other error goes to the response, and leaves both result and error undefined.

returned by the route
import {initClient} from '@mionjs/client';
import type {MyApi} from './auth-user.routes.ts';

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

// strongly typed: the route's own result or error
const [user, error] = await routes.users.getById('USER-404').call();

if (error)
  console.log(error.type); // 'user-not-found' | 'validation-error'
else if (user) console.log(user.name); // John
failed outside the route
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-123')
  .call({timeout: 5000});

// both undefined: the error came from outside the route
if (!user && !error) {
  // a middleware's declared error, typed, under its name
  if (isRpcError(response.auth))
    console.log('auth failed:', response.auth.type); // 'not-authorized'
  // anything nobody declared: a timeout, an abort, a network failure, a thrown error
  for (const thrown of response['@thrownErrors'] ?? [])
    console.log('failed:', thrown.publicMessage);
}
The client builds the result, the server never sees it. Your handlers just return a value or an RpcError.

Handling Typed Errors

When routes declare specific error types, you get autocompletion and type checking on error.type and on error.errorData.

client.ts
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);
}
server.routes.ts
import {RpcError, FatalError} from '@mionjs/core';
import {createMionRouter, Routes} from '@mionjs/router';
import {getSession} from './session.ts';

const mion = createMionRouter();

export type User = {id: string; name: string};
export type NotAuthorizedData = {reason: 'no-session' | 'expired-session'};
export type UserNotFoundData = {requestedId: string};

const routes = {
  // reads the HttpOnly session cookie; a returned FatalError ends the request and reaches the client typed, errorData included
  auth: mion.middleware(
    (ctx): void | FatalError<'not-authorized', NotAuthorizedData> => {
      if (!getSession(ctx.request.headers.get('cookie'))) {
        return new FatalError({
          publicMessage: 'Not Authorized',
          type: 'not-authorized',
          errorData: {reason: 'no-session'},
        });
      }
    }
  ),
  users: {
    getById: mion.route(
      (
        ctx,
        id: string
      ): User | RpcError<'user-not-found', UserNotFoundData> => {
        if (id !== 'USER-123') {
          return new RpcError({
            publicMessage: `User ${id} not found`,
            type: 'user-not-found',
            errorData: {requestedId: id},
          });
        }
        return {id, name: 'John'};
      }
    ),
  },
} satisfies Routes;

const myApi = mion.initRoutes(routes);

export type MyApi = typeof myApi;

Middleware Errors

A middleware's declared errors reach its onError hook and its path in the response, both strongly typed. Use the hook for app-wide handling, like a logout on not-authorized. To send a middleware its data, see Middleware Hooks.

Hooks run on every request that includes the middleware, whether or not you also read the response. Every onError for an error type runs, in the order added.
Copyright © 2026