Skip to content

Schemas ​

A schema is a class that describes one table. Its instances are the rows you get back from the database.

ts
export class User extends Schema("users", {
  id: t.integer().primaryKey().dbGenerated(),
  email: t.string(),
  name: t.string().nullable(),
  role: t.enum(["admin", "member"]).default("member"),
  loginCount: t.integer().default(0),
  password: t.string().virtual(),
  passwordHash: t.string().redact().column("pw_hash"),
  settings: t.json<{ theme: string }>().nullable(),
  ...timestamps(),
}) {
  get displayName() {
    return this.name ?? this.email;
  }
}

The examples on this page import these:

ts
import {
  belongsTo,
  EmbeddedSchema,
  fieldNames,
  hasMany,
  type InsertShape,
  isRow,
  isSchema,
  type Json,
  meta,
  primaryKeyOf,
  type Row,
  Schema,
  stateOf,
  t,
  timestamps,
} from "egon-orm";

Schema(table, fields, options?) returns a base class to extend. The class name is the row type: a function that takes a User takes a row of the users table.

Fields ​

Every field starts with a type from t and may chain modifiers. See Types for the full list of types and what each accepts.

ModifierEffect
.nullable()The column may be NULL; the TypeScript type gains | null. A new record starts with null.
.default(value | () => value)Used for a new record when no value is given. A function is called once per record.
.primaryKey()Part of the primary key. See Primary keys.
.autogenerate(fn?)A value generated in your process on insert when none is given (UUIDs, timestamps).
.dbGenerated()The database produces the value; it is read back after insert and update. A serial key is .primaryKey().dbGenerated().
.virtual()Exists on the row and in changesets, but is never stored or selected, and is left out of the row's JSON.
.virtual({ json: true })A virtual field that the row's JSON includes when it is set.
.column("name")The column name, when it is not the snake_case form of the field name.
.redact()Hidden from console.log, logs and error messages, and never in JSON, whatever other modifiers say.
.writable("always" | "insert" | "never")Restricts when the field may be written. The default is "always".

Every modifier returns a new field, so a field can be declared once and shared by several schemas.

Fields are non-null by default. A field without .nullable() is string, not string | null, and the library enforces it: see Required fields.

Use a function for a default that is an object or an array, such as t.json<string[]>().default(() => []). A value is shared by every new record, and changing it in one record would change it in all of them.

.autogenerate() without a function works for two types: t.uuid() gets a UUID version 7 (time-ordered, so new keys go to the end of the index) and the timestamp types get the current time. For anything else, pass the function: t.uuid().autogenerate(() => crypto.randomUUID()) for a random version 4 UUID.

Primary keys ​

ts
serial: t.integer().primaryKey().dbGenerated(), //                 32-bit serial / identity column
big: t.bigint({ mode: "number" }).primaryKey().dbGenerated(), //   64-bit bigserial, as a JS number
uuid: t.uuid().primaryKey().autogenerate(), //                     UUID v7 generated in your process
natural: t.string().primaryKey(), //                               natural key; you supply the value
  • .primaryKey() marks the key and nothing else: the value is one you supply, whatever its type. Add .dbGenerated() when the database generates it (a serial, bigserial or identity column), or .autogenerate() when your process does (a UUID).
  • The same holds for every part of a composite key, so a join table's two foreign keys are plain .primaryKey().
  • Several fields with .primaryKey() form a composite key, in declaration order:
ts
export class Membership extends Schema("memberships", {
  orgId: t.uuid().primaryKey(),
  userId: t.integer().primaryKey(),
  role: t.string(),
}) {}
  • A table with no key is declared with { primaryKey: false }. Such rows cannot be updated or deleted individually.
ts
export class Event extends Schema("events", { name: t.string(), at: t.timestamp() }, { primaryKey: false }) {}

There is no special "id" type. A foreign key is an ordinary field of the same type as the key it points at:

ts
export class Post extends Schema("posts", {
  id: t.integer().primaryKey().dbGenerated(),
  authorId: t.integer(),
  title: t.string(),
}) {}

Timestamps ​

...timestamps() adds createdAt and updatedAt, both set on insert; updatedAt is set again on every update that changes something.

ts
timestamps(), //                                       createdAt, updatedAt
timestamps({ createdAt: "insertedAt" }), //            rename one
timestamps({ updatedAt: false }), //                   leave one out
timestamps({ type: t.timestamp({ precision: "s" }) }), // another type or precision

Both are t.timestamp() (timestamptz) unless you pass another type that holds a Date, such as t.localTimestamp(). The time is taken in your process, at the type's precision.

Schema options ​

ts
class Invoice extends Schema("invoices", fields, {
  schema: "billing", //        database schema (Postgres schema, MySQL database)
  naming: "snake_case", //     how field names become column names; the default
}) {}
OptionEffect
schemaThe database schema (a Postgres schema, a MySQL database) the table is in.
naming"snake_case" (the default), "camelCase" to use the field names as they are, or a function from field name to column name.
primaryKey: falseThe table has no primary key.
codegen: falseBuild rows without generating code. This happens by itself where generating code is forbidden (a Content Security Policy without unsafe-eval); rows are the same either way, only slower to build.

Column names ​

Field names are camelCase in TypeScript and snake_case in the database. An upper-case run counts as one word. .column() overrides one field, naming the whole schema.

ts
class Customer extends Schema("customers", {
  id: t.integer().primaryKey().dbGenerated(),
  firstName: t.string(), //                       column first_name
  vatID: t.string(), //                           column vat_id
  legacyCode: t.string().column("LEGACY_CODE"), // column LEGACY_CODE
}) {}

Two fields that end up in the same column are refused when the class is defined.

Getters and methods ​

A schema class may define getters and methods, as displayName does above. It may not define a constructor or field initialisers: rows are built without running a constructor, so neither would run.

There is no constructor ​

You never write new User(). A User object comes only from the database: from a query, or from a successful insert or update. A record that does not exist yet is described by a changeset:

ts
cast(User, params, ["email", "name"]); //   from untrusted input
change(User, { email: "a@b.c" }); //        from trusted values

Calling the constructor throws SchemaDefinitionError, which says so.

This is why a row's type can be trusted: a User whose email is undefined cannot exist.

Derived types ​

ts
type UserRow = Row<typeof User>; //            the same as the instance type, User
type NewUser = InsertShape<typeof User>; //    what an insert needs
// { email: string; id?: number; name?: string | null; role?: "admin" | "member"; ... }

InsertShape makes a field optional when it is nullable, has a default, is autogenerated or is database-generated. Virtual fields are left out.

A row with preloaded associations has the type Loaded<typeof User, "posts">; see Associations.

Associations ​

Associations are static members of the class. The foreign key is always a field you declare; the association refers to it by field name.

ts
class Author extends Schema("authors", { id: t.integer().primaryKey().dbGenerated(), name: t.string() }) {
  static books = hasMany(() => Book, { foreignKey: "authorId" });
}

class Book extends Schema("books", {
  id: t.integer().primaryKey().dbGenerated(),
  authorId: t.integer(), // the foreign key is a field you declare
  title: t.string(),
}) {
  static author = belongsTo(() => Author); // foreignKey defaults to "authorId"
}

Where the keys are, for each kind:

belongsTo   books.author_id ──────────▶ authors.id        the key is on this table
hasOne      authors.id      ◀────────── profiles.author_id the key is on the other table
hasMany     authors.id      ◀────────── books.author_id    (any number of rows)
manyToMany  posts.id ◀── posts_tags.post_id                a join table holds
                         posts_tags.tag_id ──▶ tags.id     both keys

hasOne and hasMany need foreignKey; belongsTo defaults it to the property name plus Id. Associations are checked the first time they are used, not when the class is defined, because the class they point at may not exist yet. See Associations and preloading.

Embedded schemas ​

An embedded schema has fields but no table. It is stored in a JSON column of its parent, with the field names as JSON keys (they are not converted to snake_case):

ts
class Address extends EmbeddedSchema({
  street: t.string(),
  city: t.string(),
  postCode: t.string().nullable(),
}) {}

class Shop extends Schema("shops", {
  id: t.integer().primaryKey().dbGenerated(),
  address: t.embedOne(() => Address),
}) {}

Embedded rows are instances of their class. See Embeds.

Serialisation ​

JSON.stringify(user) (and so res.json(user), Response.json(user)) writes the row's JSON: its fields, then its preloaded associations, with each value made JSON-ready. Json<User> is that shape as a type, for the client:

ts
type UserJson = Json<User>; // what JSON.stringify(user) writes, and what a client receives
// { id: number; email: string; name: string | null; role: "admin" | "member"; loginCount: number;
//   settings: { theme: string } | null; createdAt: string; updatedAt: string }
// No password (virtual), no passwordHash (redacted), no displayName (a getter).

class Member extends Schema("members", {
  id: t.integer().primaryKey().dbGenerated(),
  joinedAt: t.date({ as: "date" }), //            "2026-10-22" in JSON
  points: t.bigint(), //                         "9007199254740993" in JSON
  avatar: t.binary().nullable(), //              base64 in JSON
  badge: t.string().virtual({ json: true }), //  set in code, and sent when it is set
}) {}
type MemberJson = Json<Member>;
// { id: number; joinedAt: string; points: string; avatar: string | null; badge?: string }
Field typeIn JSON
t.timestamp(), t.localTimestamp()An ISO string, "2026-10-22T09:30:00.000Z".
t.date({ as: "date" })The calendar date, "2026-10-22".
t.date(), t.time(), t.decimal(), t.uuid(), t.string(), t.enum()Unchanged (strings).
t.bigint()A decimal string, so no precision is lost (JSON.stringify of a plain bigint throws).
t.bigint({ mode: "number" }), t.integer(), t.float()A number; NaN and ±Infinity become null.
t.binary()A base64 string.
t.boolean()A boolean.
t.json<T>(), t.array(…), embedsMapped element by element, field by field.
A custom typeIts value's own toJSON() when it has one; otherwise mapped by its value.
  • Redacted fields are never in JSON. Virtual fields are left out too, unless declared .virtual({ json: true }); such a field is written when it has a value, so it is optional in Json<…>.
  • Preloaded associations and embeds are mapped the same way at every level; an association that was not preloaded is absent. Getters and methods are not written.
  • console.log(user) shows redacted fields as **redacted**.
  • toJSON lives on the class's prototype, and is not part of the row's type: call JSON.stringify, or type an API's response with JsonReturn (see Typing responses). A schema class may define its own toJSON; it is used at every level, and Json of the row is then what it returns.

Good to know

Send rows, not copies of them. A copy ({ ...user }, const { email, ...rest } = user) is a plain object, which JSON.stringify writes in full, redacted and virtual fields included, and Json of it says so. Json of a mapped type such as Omit<User, "email"> still describes the row.

Reflection ​

ts
meta(User).source; //                          "users"
meta(User).primaryKey.map((f) => f.name); //   ["id"]
meta(User).assocs(); //                        the associations, by name
fieldNames(User); //                           ["id", "email", "name", ...]
isSchema(User); //                             true
isRow(user); //                                true
primaryKeyOf(user, User); //                   1, typed number
stateOf(user); //                              "loaded"
FunctionReturns
meta(User)The schema's metadata: source, fields, primaryKey, assocs().
fieldNames(User)The field names, in declaration order.
isSchema(x) / isRow(x)Type guards.
primaryKeyOf(row, User?)The key value, or an object for a composite key. Typed when the class is passed.
stateOf(row)"loaded" or "deleted".
User.fieldsThe field declarations the class was defined with.

Reserved names ​

A field cannot be named constructor, __proto__ or toJSON, or start with $. An association cannot have those names either, nor fields. A mistake in a schema throws SchemaDefinitionError when the class is defined, with a message that names the schema, the field and the fix.

Good to know

  • No constructor. Rows come from the database; new records are changesets. A schema class holds getters and methods only.
  • Non-null by default. Add .nullable() where the column allows NULL.
  • camelCase fields, snake_case columns. createdAt is stored in created_at.
  • You declare the foreign key. belongsTo(() => User) does not add userId; the field must exist, and is checked the first time the association is used.

Why classes and static associations ​

Two schemas that refer to each other (a user has posts, a post has an author) must both be typed without either being written first. A schema as a constant (const User = schema({ posts: hasMany(() => Post) })) makes TypeScript give up on such a cycle and type it as any. A class whose fields are in its base and whose associations are static members does not: the base mentions only fields, and TypeScript works out static members lazily. The class also gives the row type a name, User, and a place for getters and methods. The decision and the experiment behind it are recorded in ADR 0001.

See also ​