Server

Drizzle Tables

Type routes with the models of your Drizzle ORM tables.

Routes take and return the models of your Drizzle ORM tables like any other type. The RunTypes Drizzle ORM section covers declaring tables, column formats, views and migrations.

Using Models in Routes

A route typed with your table models is validated before the handler runs, refined bounds included. Dates stay Dates on server and client, the client gets full types, and you register nothing per route. Both table forms give the same models, so only the import differs.

import {RpcError} from '@mionjs/core';
import {createMionRouter} from '@mionjs/router';
import type {NewUser, User, UserPatch} from './drizzle-refine-example.ts';

const mion = createMionRouter();

const usersStore = new Map<string, User>();

export const usersApi = mion.initRoutes({
  users: {
    // id and createdAt are optional (table defaults), so the handler fills them
    insert: mion.route((_ctx, user: NewUser): User => {
      const row: User = {
        id: user.id ?? crypto.randomUUID(),
        name: user.name,
        age: user.age,
        createdAt: user.createdAt ?? new Date(),
      };
      usersStore.set(row.id, row);
      return row;
    }),

    // createdAt arrives on the client as a real Date
    select: mion.route(
      (_ctx, id: string): User | RpcError<'user-not-found'> => {
        return (
          usersStore.get(id) ??
          new RpcError({
            publicMessage: 'User not found',
            type: 'user-not-found',
          })
        );
      }
    ),

    // any subset is accepted, but a too-short name is still rejected
    update: mion.route(
      (
        _ctx,
        id: string,
        patch: UserPatch
      ): User | RpcError<'user-not-found'> => {
        const existing = usersStore.get(id);
        if (!existing)
          return new RpcError({
            publicMessage: 'User not found',
            type: 'user-not-found',
          });
        const next: User = {...existing, ...patch};
        usersStore.set(id, next);
        return next;
      }
    ),
  },
});

// the client's types come from this
export type UsersApi = typeof usersApi;

Returning Query Results

A row you query from a toDrizzle table has the model's type, formats included. A route can return it as is, and the client checks the same lengths, ranges and ids.

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}})
  ),
});
Write route return types from your models for an explicit public contract with the same TypeFormats parameters.

Keeping Drizzle Types Out of Routes

Route parameters and return types use slim models, never Drizzle types, so the client never type-checks or bundles your database code. The linter warns when a route uses a Drizzle type.

Copyright © 2026