Introduction ​
Work in progress
Egon ORM is under construction and nothing is published to npm yet.
Egon ORM is a TypeScript ORM for Postgres, SQLite and MySQL. It keeps four things apart that many ORMs blend together:
| Concept | What it is | Page |
|---|---|---|
| Schema | A class that describes a table: its fields, types and associations. Rows are instances of it. | Schemas |
| Changeset | A description of a change to one record: the filtered input, the validation errors, the constraints to check. | Changesets |
| Query | An immutable value that describes what to read or bulk-write. Nothing runs when you build one. | Queries |
| Repo | The only thing that talks to the database. It runs queries and writes changesets. | Repo |
ts
import { postgres } from "@egon-orm/postgres";
import { belongsTo, cast, count, createRepo, from, hasMany, Schema, t, timestamps } from "egon-orm";
// Over two tables with serial (32-bit) keys, which t.integer() reads.
class User extends Schema("users", {
id: t.integer().primaryKey().dbGenerated(),
email: t.string(),
name: t.string().nullable(),
...timestamps(),
}) {
static posts = hasMany(() => Post, { foreignKey: "authorId" });
static changeset(user: User | typeof User, params: unknown) {
return cast(user, params, ["email", "name"]).validateRequired(["email"]).uniqueConstraint("email");
}
}
class Post extends Schema("posts", {
id: t.integer().primaryKey().dbGenerated(),
authorId: t.integer(),
title: t.string(),
}) {
static author = belongsTo(() => User, { foreignKey: "authorId" });
}
const repo = createRepo({ adapter: postgres({ connectionString: process.env.DATABASE_URL }) });
const result = await repo.insert(User.changeset(User, { email: "ada@example.com" }));
if (!result.success) console.log(result.error.errorMessages()); // if the email is taken: { email: ["has already been taken"] }
const authors = await repo.all(
from(User, "u")
.where(({ u }) => u.name.isNotNull())
.preload("posts"),
);
authors[0]?.posts[0]?.title; // string: the type knows "posts" was preloaded
const stats = await repo.all(
from(User, "u")
.leftJoinAssoc("u", "posts", "p")
.groupBy(({ u }) => u.id)
.select(({ u, p }) => ({ email: u.email, posts: count(p.id) })),
); // { email: string; posts: number }[]Why keep them apart ​
- Data is just data. A row is a typed object the database returned. It has no connection, no dirty flags and no
save(), so passing it around, caching it or sending it to a client cannot trigger a query. - A change is a value you can inspect. Each form is a function from input to a changeset: it permits its own fields, validates them, and declares which database constraints should come back as form errors. You can test it without a database.
- A query is a value you can compose. A function can take a query and return a narrower one; a base query can be kept in a constant. The SQL that runs is the SQL the query describes.
- One object does I/O. Everything that touches the database goes through the repo, or through
tx, the repo bound to a transaction. What runs inside a transaction is visible in the code.
How it differs from other TypeScript ORMs ​
These are differences in approach, not a ranking; each tool makes sensible choices for its goals.
| Egon | Prisma | Drizzle | TypeORM, Sequelize | |
|---|---|---|---|---|
| Where the schema lives | TypeScript classes | a .prisma file, compiled by prisma generate | TypeScript table objects | decorated entity classes or model definitions |
| Reading related rows | preload, typed per query | include / select | relational queries (with) or joins | eager include / relations, and lazy loading |
| Validating input | changesets, with built-in rules or any Standard Schema library | not built in | not built in (drizzle-zod derives schemas) | Sequelize model validators; TypeORM uses class-validator |
| A rejected write | a result: { success: false, error } with field errors | an exception (P2002 for a unique violation) | an exception (a DrizzleQueryError wrapping the driver's error) | an exception |
| Transactions | explicit: only calls on tx are inside | explicit ($transaction) | explicit (db.transaction) | explicit; Sequelize can pass them implicitly through CLS |
| Migrations | written by hand | generated from schema changes | generated from schema changes (drizzle-kit) | TypeORM: generated or written by hand; Sequelize: written by hand |
What you get from the Egon approach:
- Typed end to end without a build step. Row types, preloaded associations and
selectresults are inferred from the classes. A row loaded withoutpostshas nopostsproperty in its type. - Forms and database errors in one shape. A unique index violated at write time comes back as
{ email: ["has already been taken"] }, next to the validation errors. - No hidden queries. No lazy loading and no query you did not write. Nothing joins a transaction you did not open; a single call that writes several statements (a parent with its children, a chunked
insertAll) is atomic on its own.
What it costs, honestly:
- More explicit code. A form needs a changeset function; a page needs its preloads listed. Where another ORM guesses, Egon asks.
- Migrations are yours to write. Nothing is diffed from your schema classes, and keeping the classes and the database in step is your job (
egon migrate create <name>writes an empty one to fill in). - Results to check.
insertreturns a result you must look at, or useinsertOrThrow. - No identity map. The same record loaded twice is two objects.
- Young. Nothing is published yet, the ecosystem is small, and SQLite and MySQL have documented gaps.
What to expect if you come from another ORM ​
- No lazy loading. An association is on a row only if you preloaded it, and the type says so.
user.postson a row loaded without it is a compile error. - Rows are data. There is no
user.save(). You describe a change with a changeset and hand it to the repo. - New records are changesets, not objects. You never write
new User(). AUserobject exists only after the database returned one. - Writes return a result.
repo.insertreturns{ success: true, data }or{ success: false, error }, whereerroris the changeset with its errors.repo.insertOrThrowthrows instead. - Fields are non-null by default.
.nullable()opts in. - Transactions are explicit. Only calls on the transaction object are inside it. A single write that spans several statements opens its own, so it is all or nothing.
- Every value is a bound parameter. Raw SQL is possible only through the
fragmenttemplate, whose interpolations are bound too.
The pages for Prisma, Drizzle and Sequelize users map common tasks one by one.
Naming conventions ​
- Functions and options are camelCase.
- A method that returns
nullor a result has a throwing twin ending inOrThrow:get/getOrThrow,insert/insertOrThrow. - Field names are camelCase in TypeScript and snake_case in the database (
createdAtis the columncreated_at), unless a field says otherwise with.column("..."). - The words used throughout these pages are defined in the Glossary.
See also ​
- Getting started: from install to a first query.
- Tutorial: a small blog, step by step.
- FAQ and troubleshooting: the errors people hit first.

