kastor0.1.0
Get started

GUIDE 01TypeScript ORM

Choosing a TypeScript ORM: what matters and how kastor works

A TypeScript ORM maps your tables to typed objects so the compiler checks every query. This guide explains what to look for and walks through kastor from the first table to a production migration.

What a TypeScript ORM actually does

An ORM (object relational mapper) sits between your code and the database. You describe your tables once, and the ORM turns method calls like findMany into SQL, sends it through your driver and turns the rows back into plain objects.

A TypeScript ORM adds one more job: it knows the shape of every table, so the compiler can check your queries. If you select a column that does not exist, filter a number with a string or forget a required field on insert, you see a red line in the editor instead of an error in production.

That type information has to come from somewhere. Some tools read a separate schema file and generate a client. Others let you write the schema in TypeScript and infer the types from it. Both work, but they feel different day to day, and that difference is the first thing to understand when you compare options.

What to look for before you pick one

Most comparisons focus on syntax. Syntax matters less than these five properties, because they decide how much friction you hit over the life of a project.

  • Inference without codegen. When the schema is TypeScript and types are inferred, a new column is available in queries the moment you save the file. There is no generated client to forget to rebuild and no extra step in CI.
  • Every database you might use. Projects change. A startup that begins on SQLite moves to PostgreSQL, a company project has to talk to SQL Server or Oracle, an event store lives in MongoDB. One API across all of them means your team learns it once.
  • Migrations you can review. The ORM should diff your schema against the live database, write a plain SQL file you can read in a pull request, and apply pending files in production with one command. Rename detection matters: a renamed column should not become a drop plus an add.
  • No N+1 queries. Loading users with their posts should never run one query per user. Check how relations are loaded, not just whether they are supported. The N+1 guide explains why this is the most common performance bug in ORM code.
  • A raw SQL escape hatch. Window functions, recursive CTEs and vendor specific features will come up. You want raw SQL that still uses bound parameters and still returns typed rows.

kastor was built around these five points. The rest of this guide is a quick tour, so you can judge it against your own list.

Step 1: schema and client

Install kastor and the driver for your database. kastor ships no drivers, so you stay in control of versions and pooling. For PostgreSQL that is npm install kastor-orm pg, for SQLite npm install kastor-orm better-sqlite3.

The schema is plain TypeScript. Each table() call declares columns with the t builders and returns an object you can import anywhere.

src/db/schema.ts
import { table, t, index } from 'kastor-orm'

export const users = table('users', {
  id: t.id(),
  email: t.string(255).unique(),
  name: t.string().nullable(),
  role: t.enum(['user', 'admin']).default('user'),
  createdAt: t.datetime().defaultNow(),
})

export const posts = table('posts', {
  id: t.uuid().primaryKey().defaultRandom(),
  title: t.string(200),
  body: t.text().nullable(),
  published: t.boolean().default(false),
  authorId: t.int().references(() => users.id, { onDelete: 'cascade' }),
  createdAt: t.datetime().defaultNow(),
}, { indexes: (p) => [index(p.authorId)] })

The client combines an adapter, the schema and the relations between tables. Relations are declared once and used by include and relation filters.

src/db/client.ts
import pg from 'pg'
import { kastor, one, many } from 'kastor-orm'
import { postgres } from 'kastor-orm/postgres'
import { users, posts } from './schema'

export const db = kastor({
  adapter: postgres(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  schema: { users, posts },
  relations: ({ users, posts }) => ({
    users: { posts: many(posts) },
    posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
  }),
})

Hover db.users in your editor and you will see every method with the fields of your table. Switching to MySQL or SQLite means changing only the adapter line.

Step 2: queries, relations and transactions

Queries take an object with where, select, include, orderBy, take and skip. The return type follows your arguments: select two columns and the result has exactly those two keys.

src/routes/feed.ts
import { isKastorError } from 'kastor-orm'
import { db } from '../db/client'

// Users with their five latest published posts, one root query
const authors = await db.users.findMany({
  where: { role: 'admin', email: { endsWith: '@example.com', mode: 'insensitive' } },
  include: {
    posts: { where: { published: true }, orderBy: { createdAt: 'desc' }, take: 5 },
    _count: { select: { posts: true } },
  },
  orderBy: { createdAt: 'asc' },
})

// Narrow the row type with select
const emails = await db.users.findMany({ select: { id: true, email: true } })

// Nested write: the user and the first post in one call
const ana = await db.users.create({
  data: { email: 'ana@example.com', posts: { create: [{ title: 'Hello' }] } },
})

// Transactions
await db.$transaction(async (tx) => {
  await tx.posts.updateMany({ where: { authorId: ana.id }, data: { published: true } })
  await tx.users.update({ where: { id: ana.id }, data: { role: 'admin' } })
})

// Readable errors with a hint
try {
  await db.users.create({ data: { email: 'ana@example.com' } })
} catch (e) {
  if (isKastorError(e) && e.code === 'UNIQUE_VIOLATION') console.log(e.message, e.hint)
}

A few details are worth knowing early:

  • Queries are lazy. Nothing runs until you await, and query.toSQL() returns the statements without executing them. Open the playground to see the exact SQL for each database.
  • Nested includes load in one query per root call on every SQL adapter (PostgreSQL, CockroachDB, MySQL, MariaDB, the SQLite family including libSQL and D1, SQL Server and Oracle), which build the relations with JSON aggregation. On MongoDB the same include becomes $lookup stages inside one aggregation, so no adapter runs one query per row.
  • Errors are KastorError instances with a stable code, a plain English message and a hint that suggests the fix.
  • When the object API is not enough, db.$sql runs raw SQL with bound parameters and a typed result. The type safe SQL guide covers it in depth.

Step 3: migrations from the TypeScript schema

Create a kastor.config.ts that points to the schema file, an adapter factory, a migrations folder and an optional seed script. Running npx kastor-orm init writes one for you, together with a starter src/db/schema.ts, a src/db/seed.ts and a .env with DATABASE_URL, which the CLI reads from the folder of the config.

From then on the loop is short: edit schema.ts, run migrate dev, commit the generated SQL file. kastor compares the TypeScript schema with the live database, writes the difference to a timestamped folder and applies it. If a column disappears and a compatible one appears, it asks whether you renamed it instead of silently dropping data. Outside an interactive terminal it stops and asks you to pass --rename users.name:fullName or --accept-data-loss.

Terminal
npx kastor migrate dev --name add_posts
npx kastor migrate status
npx kastor migrate deploy

The file it writes is plain SQL for your dialect, with a short header that lists the changes, so reviewers can read it in the pull request:

kastor/migrations/20261013120000_add_posts/migration.sql
-- Migration 20261013120000_add_posts
-- Written by kastor 0.1.0 for postgres on 2026-10-13 12:00:00 UTC.
-- Applied by `kastor migrate deploy`. Edit it before it is applied if you need to;
-- after that, the checksum in _kastor_migrations must keep matching.
--
-- Changes:
--   + Create table posts

-- Create table posts
CREATE TABLE "posts" (
  "id" uuid DEFAULT gen_random_uuid() NOT NULL,
  "title" varchar(200) NOT NULL,
  "body" text,
  "published" boolean DEFAULT false NOT NULL,
  "authorId" integer NOT NULL,
  "createdAt" timestamptz(3) DEFAULT CURRENT_TIMESTAMP NOT NULL,
  CONSTRAINT "posts_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "posts_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
);
CREATE INDEX "posts_authorId_idx" ON "posts" ("authorId");

In CI or on deploy, run npx kastor migrate deploy: it applies pending files in order and never generates new ones. For quick prototypes, npx kastor db push syncs the schema without writing a file. Already have a database? npx kastor db pull introspects it into a TypeScript schema you can start from, with relations guessed from the foreign keys. The migrations guide covers team workflows, and npx kastor studio opens a local UI to browse data with an ER diagram.

When an ORM is not the right tool

An ORM pays off when most of your queries are reads and writes on a handful of related tables. That describes most web apps, but not all database work. Consider something else when:

  • The work is mostly analytics. Reports with many joins, window functions and grouping sets read better as SQL. Keep them in .sql files or use db.$sql for the few that live next to app code.
  • You run bulk data pipelines. Loading millions of rows is a job for the database's own tools, such as COPY in PostgreSQL or bcp in SQL Server.
  • The database is shared and owned by another team. If you cannot run migrations, you still get typed queries from db pull, but most of the value of a schema in code goes away.
  • A script needs one query. For a ten line maintenance script, the driver alone is fine.

Most projects end up in the middle: the ORM for everyday CRUD and relations, raw SQL for the few hot or complex paths. Pick a tool that makes both easy instead of one that forces a choice.

Note

Next steps: follow the quick start, check the setup for your framework in the frameworks page, or read the full API reference.

Questions people ask

Q1What is the best TypeScript ORM?

It depends on your database and how you like to write queries. Check four things: whether types are inferred without a generate step, whether it supports your database, how it handles migrations and whether relation loading avoids N+1 queries. kastor covers all four with one API for SQL databases and MongoDB.

Q2Does a TypeScript ORM need code generation?

Not necessarily. Some ORMs generate a client from a schema file, others infer types directly from a schema written in TypeScript. kastor infers everything as you type, so there is no generate command in development or CI.

Q3Can a TypeScript ORM run raw SQL?

A good one should. kastor has db.$sql as a tagged template that turns every interpolated value into a bound parameter, and sql fragments you can drop into a where clause. See type safe SQL in TypeScript.

Q4Is an ORM slower than writing SQL by hand?

The overhead of building a query is small compared to a network round trip. What makes an app slow is the number and shape of queries, which is why relation loading matters more than the ORM itself. kastor loads nested includes in one query on databases with JSON aggregation.

Q5Which databases does kastor support?

PostgreSQL, CockroachDB, MySQL, MariaDB, SQLite (better-sqlite3, node:sqlite, bun:sqlite), libSQL and Turso, Cloudflare D1, SQL Server, Oracle and MongoDB. You install the driver yourself and pass it to the adapter.

Suggestions

Kas looked under every log. Nothing matches that yet.

Try a method name, a database or a framework.