Devtools

Linter

Lint rules that catch mistakes in mion routes as you write them.

@mionjs/devtools/eslint is a lint plugin for OXlint and ESLint that catches mistakes in mion routes, middleware and types as you write them. It ships inside @mionjs/devtools.

The rules check your real types, the same way the build does. Every handler is checked, however you write it.

Lint Rules

The plugin has one rule per level, and each message starts with its name, like [rpc-handler-missing-return-type]. The route checks below and the RunTypes checks both report through them.

RuleWhat it reportsRecommended
mion/errorEvery Error: no code was made, so the build stops✅ Error
mion/runtime-errorEvery RuntimeError: the code throws when called, or no longer checks what you asked for, so the build stops✅ Error
mion/warningEvery Warning, and every error you lowered⚠️ Warn
mion/infoEvery Info message: documented behaviour or adviceOff
The four rules are the same in every app and cannot turn one check on or off. To allow one error, add a @mion-expect-error comment above it or list its name in downgradeErrors in your tsconfig: your editor and your build both follow it.

Installing the Plugin

npm install @mionjs/devtools -D

The recommended config turns on the four level rules. The plugin is ESM only, so ESLint needs its flat config (eslint.config.js), not .eslintrc.json. The RunTypes linting page shows the OXlint config.

eslint.config.js
import mion from '@mionjs/devtools/eslint';

export default [mion.configs.recommended];
The rules find tsconfig.json the way tsc does, from the directory the linter runs in. Point them at another file with settings: {mion: {tsconfig: 'tsconfig.lint.json'}}.

Showing Info Messages

The linter hides Info messages, which mark code that works as documented: a method a validator skips, a validator on a type you wrote as any. Turn on mion/info to show them.

eslint.config.js
import mion from '@mionjs/devtools/eslint';

export default [mion.configs.recommended, {rules: {'mion/info': 'warn'}}];

Route Checks

Public Drizzle Types

drizzle-type-not-allowed warns when a written public parameter or return type uses a Drizzle ORM type. Use slim models or plain types, including nested results and selected fields.

import {eq} from 'drizzle-orm';
import {drizzle} from 'drizzle-orm/pg-proxy';
import {createMionRouter} from '@mionjs/router';
import {toDrizzle} from '@mionjs/drizzle-orm-pg-core/drizzle';
import {schema} from './drizzle-relations-example.ts';
import {
  authors,
  posts,
  type Author,
  type Post,
  type AuthorWithPosts,
} from './drizzle-relations-schema-example.ts';

const authorsDb = toDrizzle(authors);
const postsDb = toDrizzle(posts);
const db = drizzle(async () => ({rows: []}), {schema});

const mion = createMionRouter();

export const blogApi = mion.initRoutes({
  // whole rows
  listAuthors: mion.route(
    async (): Promise<Author[]> => db.select().from(authorsDb)
  ),

  // selected model fields
  authorNames: mion.route(
    async (): Promise<Pick<Author, 'id' | 'name'>[]> =>
      db.select({id: authorsDb.id, name: authorsDb.name}).from(authorsDb)
  ),

  // one model per joined table
  postsWithAuthor: mion.route(
    async (): Promise<{post: Post; author: Author}[]> =>
      db
        .select({post: postsDb, author: authorsDb})
        .from(postsDb)
        .innerJoin(authorsDb, eq(postsDb.authorId, authorsDb.id))
  ),

  // nested models
  authorsWithPosts: mion.route(
    async (): Promise<AuthorWithPosts[]> =>
      db.query.authors.findMany({with: {posts: true}})
  ),
});
Your handler body can use Drizzle queries and types; this rule currently skips context parameters. An inferred Drizzle database in contextDataFactory can slow client type checking, even when public types are slim.

Separating Database Code

drizzle-mixed-types reports a RuntimeError when a file defining slim tables or models imports Drizzle or toDrizzle. Keep them in one file, and toDrizzle, connections, relations and queries in a companion file. Clients then type-check and bundle only the models, never your database code (measured).

import * as DZ from '@mionjs/drizzle-orm-pg-core';
import {tableRef, type InferSelectModel} from '@mionjs/drizzle-orm';

export const authors = DZ.pgTable('authors', {
  id: DZ.uuid('id', {defaultRandom: true, primaryKey: true}),
  name: DZ.varchar('name', {length: 100, notNull: true}),
});

export const posts = DZ.pgTable('posts', {
  id: DZ.uuid('id', {defaultRandom: true, primaryKey: true}),
  authorId: DZ.uuid('author_id', {
    notNull: true,
    references: [() => tableRef(authors, 'id'), {onDelete: 'cascade'}],
  }),
  title: DZ.varchar('title', {length: 200, notNull: true}),
});

// route and client models
export type Author = InferSelectModel<typeof authors>;
export type Post = InferSelectModel<typeof posts>;

// start-nested-model
export interface AuthorWithPosts extends Author {
  posts: Post[];
}
// end-nested-model
Router and query files may import slim models and Drizzle unless they define slim schemas or models.

Typed Handlers

rpc-handler-missing-return-type and rpc-handler-missing-param-type report a route or middleware handler without explicit return or parameter types. mion builds validation and the JSON round trip from those types, so without them it cannot check incoming data or encode responses. Only the call context needs no type, since it is never sent over the network.

✅ Valid Examples

Inline handlers:

mion.route((ctx, name: string): string => `hello ${name}`);
mion.middleware((ctx, data: number): void => {
  console.log(data);
});
mion.headersMiddleware(
  (ctx: CallContext, {headers}: HeadersSubset<'x-trace-id'>): void => {
    // do something
  }
);

Function references:

function validHandler(ctx, name: string): string {
  return `hello ${name}`;
}
const validArrowHandler = (ctx, name: string): string => `hello ${name}`;
mion.route(validHandler);
mion.route(validArrowHandler);

Type annotations:

const typedHandler: Handler = (ctx, name: string): string => `hello ${name}`;
const typedHeaderHandler: HeaderHandler = (
  ctx: CallContext,
  {headers}: HeadersSubset<'x-trace-id'>
): void => {
  const traceId = headers['x-trace-id'];
  console.log(traceId);
};

satisfies expressions:

const satisfiesHandler = ((ctx, name: string): string =>
  `hello ${name}`) satisfies Handler;
const satisfiesHeaderHandler = ((
  ctx: CallContext,
  {headers}: HeadersSubset<'x-trace-id'>
): void => {
  const traceId = headers['x-trace-id'];
  console.log(traceId);
}) satisfies HeaderHandler;

JSDoc tags:

/**
 * @mion:route
 */
function routeWithJSDoc(ctx, name: string): string {
  return `hello ${name}`;
}

/**
 * @mion:middleware
 */
const middlewareWithJSDoc = (ctx, data: number): void => {
  console.log(data);
};

/**
 * @mion:headersMiddleware
 */
function headersMiddlewareWithJSDoc(
  ctx: CallContext,
  {headers}: HeadersSubset<'x-trace-id'>
): void {
  const traceId = headers['x-trace-id'];
  console.log(traceId);
}

❌ Invalid Examples

Inline handlers:

mion.route((ctx, name) => `hello ${name}`); // Missing both param type and return type
mion.middleware((ctx, data: number) => {
  console.log(data);
}); // Missing return type
mion.headersMiddleware((ctx: CallContext, [traceId]): void => {
  // do something
}); // Missing param type

Function references:

function invalidHandler(ctx, name) {
  return `hello ${name}`;
}
const invalidArrowHandler = (ctx, name) => `hello ${name}`;
mion.route(invalidHandler); // Should error: missing both types
mion.route(invalidArrowHandler); // Should error: missing both types

Returning Errors Instead of Throwing

rpc-handler-throws reports a throw that escapes a route, query, mutation, middleware or headersMiddleware handler. A returned error stays in the signature, so the client gets it typed. A thrown one goes to @thrownErrors, the client sees only its public message, and the declared return type is no longer true.

return new RpcError({...});   // the client gets it typed, the rest of the chain keeps running
return new FatalError({...}); // the client gets it typed, the request stops here
A throw is still the way out for an error nobody declared, such as a bug or an infrastructure failure. Add // @mion-expect-error rpc-handler-throws on the line above it, and a comment saying why.

✅ Valid Examples

// 1. Return the error, so it stays in the signature and the client gets it typed
mion.route((ctx, id: string): string | RpcError<'not-found'> => {
  if (!id)
    return new RpcError({type: 'not-found', publicMessage: 'No id given'});
  return `hello ${id}`;
});

// 2. A gate returns a FatalError, which ends the request
mion.middleware((ctx): void | FatalError<'not-authorized'> => {
  if (!ctx.request.headers.get('cookie'))
    return new FatalError({
      type: 'not-authorized',
      publicMessage: 'Not Authorized',
    });
});

// 3. A throw caught inside the handler never reaches the router
mion.route((ctx, id: string): string | RpcError<'db-error'> => {
  try {
    if (!id) throw new Error('empty id');
    return `hello ${id}`;
  } catch {
    return new RpcError({type: 'db-error', publicMessage: 'Lookup failed'});
  }
});

❌ Invalid Examples

// 1. Throwing drops the error from the signature, so the client cannot handle it typed
mion.route((ctx, id: string): string => {
  throw new RpcError({type: 'not-found', publicMessage: 'No id given'});
});

// 2. A throw inside a callback in the handler body still reaches the router
mion.route((ctx, ids: string[]): string[] =>
  ids.map((id) => {
    throw new Error('bad id');
  })
);

// 3. Rethrowing from the catch clause escapes the handler too
mion.route((ctx, id: string): string => {
  try {
    return `hello ${id}`;
  } catch (err) {
    throw err;
  }
});

Returned Error Types

rpc-handler-returns-non-rpc-error reports a handler whose declared return type can be an error that is not an RpcError. Only RpcError and its subclasses, such as FatalError or your own, get a typed slot in the response. Any other error fails the request and goes to @thrownErrors, so the code compiles but the client never gets the error its type promised.

✅ Valid Examples

// 1. An RpcError lands in its own typed slot and the chain keeps running
mion.route((ctx, id: string): string | RpcError<'not-found'> => {
  if (!id)
    return new RpcError({type: 'not-found', publicMessage: 'No id given'});
  return `hello ${id}`;
});

// 2. A FatalError is an RpcError subclass, so it is typed too, and it ends the request
mion.middleware((ctx, id: string): void | FatalError<'not-authorized'> => {
  if (!id)
    return new FatalError({
      type: 'not-authorized',
      publicMessage: 'Not Authorized',
    });
});

// 3. Your own subclass of RpcError keeps the brand, so it is fine as well
class PetNotFound extends RpcError<'pet-not-found'> {}
mion.route((ctx, id: string): string | PetNotFound => `hello ${id}`);

❌ Invalid Examples

// 1. A plain Error carries no mion brand, so it never reaches the typed slot
mion.route((ctx, id: string): string | Error => new Error('no id'));

// 2. Your own class extending Error has the same problem
class NotFoundError extends Error {}
mion.route((ctx, id: string): string | NotFoundError => new NotFoundError());

// 3. TypedError is branded, but as the base class it has no public message or status code to show
mion.route(
  (ctx, id: string): string | TypedError<'not-found'> =>
    new TypedError({type: 'not-found'})
);

Unsafe Property Names

rpc-handler-non-data-property reports a property named __proto__ in any interface, type literal or class, optional or not, at any depth. Writing that key on a plain object changes its prototype instead of storing a value, so every compiled function drops the member and its value is lost. TypeScript accepts it, so the linter reports the declaration in your editor, even for types no route uses yet.

It allows prototype and constructor.

✅ Valid Examples

interface Settings {
  builder: string;
  constructor: string;
  prototype: string;
}

class Box {
  size: number;
  constructor(size: number) {
    this.size = size;
  }
}

❌ Invalid Examples

interface Settings {
  __proto__: string;
}

type Poison = {
  __proto__: {admin: boolean};
};

API Type Imports

rpc-client-imports-server-value reports a client file that imports the API type it passes to initClient without type. A normal import can put your server code and the mion router in your client bundle. The build reports it too, as a RuntimeError.

Only that import is checked: a test can start your server and call it in the same file.

✅ Valid Examples

import type {MyApi} from './myApi.routes.ts';

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

❌ Invalid Examples

import {MyApi} from './myApi.routes.ts'; // rpc-client-imports-server-value: imported without `type`

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