Skip to content

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:

ConceptWhat it isPage
SchemaA class that describes a table: its fields, types and associations. Rows are instances of it.Schemas
ChangesetA description of a change to one record: the filtered input, the validation errors, the constraints to check.Changesets
QueryAn immutable value that describes what to read or bulk-write. Nothing runs when you build one.Queries
RepoThe 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.

EgonPrismaDrizzleTypeORM, Sequelize
Where the schema livesTypeScript classesa .prisma file, compiled by prisma generateTypeScript table objectsdecorated entity classes or model definitions
Reading related rowspreload, typed per queryinclude / selectrelational queries (with) or joinseager include / relations, and lazy loading
Validating inputchangesets, with built-in rules or any Standard Schema librarynot built innot built in (drizzle-zod derives schemas)Sequelize model validators; TypeORM uses class-validator
A rejected writea result: { success: false, error } with field errorsan exception (P2002 for a unique violation)an exception (a DrizzleQueryError wrapping the driver's error)an exception
Transactionsexplicit: only calls on tx are insideexplicit ($transaction)explicit (db.transaction)explicit; Sequelize can pass them implicitly through CLS
Migrationswritten by handgenerated from schema changesgenerated 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 select results are inferred from the classes. A row loaded without posts has no posts property 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. insert returns a result you must look at, or use insertOrThrow.
  • 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.posts on 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(). A User object exists only after the database returned one.
  • Writes return a result. repo.insert returns { success: true, data } or { success: false, error }, where error is the changeset with its errors. repo.insertOrThrow throws 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 fragment template, 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 null or a result has a throwing twin ending in OrThrow: get / getOrThrow, insert / insertOrThrow.
  • Field names are camelCase in TypeScript and snake_case in the database (createdAt is the column created_at), unless a field says otherwise with .column("...").
  • The words used throughout these pages are defined in the Glossary.

See also ​