Linter
@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.
| Rule | What it reports | Recommended |
|---|---|---|
mion/error | Every Error: no code was made, so the build stops | ✅ Error |
mion/runtime-error | Every RuntimeError: the code throws when called, or no longer checks what you asked for, so the build stops | ✅ Error |
mion/warning | Every Warning, and every error you lowered | ⚠️ Warn |
mion/info | Every Info message: documented behaviour or advice | Off |
@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
Recommended Config
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.
import mion from '@mionjs/devtools/eslint';
export default [mion.configs.recommended];
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.
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}})
),
});
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
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
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'});