Skip to content

Entities & Columns (Decorators) ​

An Entity is a TypeScript class that represents a database table. Each entity class corresponds to one table, and the class properties become the table's columns.

Prefer a code-first style?

This page covers the decorator API (@Entity, @Column, …). If you'd rather skip decorators and have the entity type inferred from the schema, see Defining Entities — the defineEntity builder. Both styles produce identical metadata and interoperate freely.

But before we write code, let's understand the problem an entity solves.

Without an ORM, you write raw SQL to create tables, then manually convert between JavaScript objects and SQL rows. Every time you add a column, you update the CREATE TABLE, the INSERT, the SELECT, and the type definitions. An entity eliminates this duplication: you describe your data structure once in TypeScript, and the ORM generates the SQL for you.

This document starts with the simplest entity and gradually introduces features needed in real-world applications.

Creating Your First Entity ​

Suppose you want to store user information in a database. The simplest entity looks like this.

typescript
// user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from "@stingerloom/orm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  name!: string;
}

With just this code, Stingerloom creates a user table with an id (auto-increment primary key) and a name (VARCHAR(255)) column.

Here is the exact DDL (Data Definition Language) that Stingerloom generates for this entity.

PostgreSQL:

sql
CREATE TABLE "user" (
  "id" SERIAL PRIMARY KEY,
  "name" VARCHAR(255) NOT NULL
);

MySQL:

sql
CREATE TABLE `user` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  PRIMARY KEY (`id`)
);

Notice the differences: PostgreSQL uses double quotes (") around identifiers and the SERIAL keyword for auto-increment. MySQL uses backticks and AUTO_INCREMENT. Stingerloom handles these differences for you -- you write the entity once and it works on both databases.

Let's look at what each of the three decorators does.

@Entity() declares that this class is an ORM entity. The class name User is automatically converted to snake_case to become the table name user. If you want to specify the table name explicitly, pass an option.

typescript
// user.entity.ts
@Entity({ name: "app_users" })
export class User { /* table name: app_users */ }

On PostgreSQL an entity can also name the schema its table lives in. A pinned table is always addressed as "schema"."table", regardless of the connection's schema option or the active tenant context -- which is what keeps a table shared across tenants in a schema-per-tenant setup. See Shared tables.

typescript
@Entity({ schema: "public" })
export class Plan { /* always "public"."plan" */ }

@PrimaryGeneratedColumn() defines an auto-increment primary key. At the SQL level, this translates to:

  • PostgreSQL: SERIAL PRIMARY KEY (which is shorthand for creating a sequence and setting it as the default)
  • MySQL: INT NOT NULL AUTO_INCREMENT PRIMARY KEY

The database automatically fills in 1, 2, 3... in sequence even if you don't provide a value during INSERT.

sql
-- You write:
INSERT INTO "user" ("name") VALUES ('Alice');
-- The DB fills in id = 1 automatically

-- You write:
INSERT INTO "user" ("name") VALUES ('Bob');
-- The DB fills in id = 2 automatically

@Column() defines a regular column. It reads the TypeScript type and automatically infers the appropriate DB type -- string becomes VARCHAR(255), number becomes INT. This inference is based on the design:type metadata that TypeScript's emitDecoratorMetadata compiler option provides.

Hint The !: syntax (definite assignment assertion) tells TypeScript "this property is managed by the ORM, so it's okay not to initialize it."

Using Various Column Types ​

In practice, you need more than just strings and numbers. You can specify the desired column type using the type option in @Column().

typescript
// product.entity.ts
@Entity()
export class Product {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  name!: string;                    // VARCHAR(255) auto-inferred

  @Column({ type: "text" })
  description!: string;             // TEXT (long strings)

  @Column({ type: "float" })
  price!: number;                   // FLOAT

  @Column({ type: "boolean" })
  isAvailable!: boolean;            // TINYINT(1) / BOOLEAN

  @Column({ type: "datetime" })
  releaseDate!: Date;               // DATETIME / TIMESTAMP
}

PostgreSQL DDL:

sql
CREATE TABLE "product" (
  "id" SERIAL PRIMARY KEY,
  "name" VARCHAR(255) NOT NULL,
  "description" TEXT NOT NULL,
  "price" REAL NOT NULL,
  "isAvailable" BOOLEAN NOT NULL,
  "releaseDate" TIMESTAMP NOT NULL
);

MySQL DDL:

sql
CREATE TABLE `product` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(255) NOT NULL,
  `description` TEXT NOT NULL,
  `price` FLOAT NOT NULL,
  `isAvailable` TINYINT(1) NOT NULL,
  `releaseDate` DATETIME NOT NULL,
  PRIMARY KEY (`id`)
);

If type is omitted, it is automatically inferred from the TypeScript type. However, even for the same string, a short name (varchar) and a long body (text) are different, so it's best to specify based on the intended use. A VARCHAR(255) column has a hard upper limit of 255 characters, while TEXT can store megabytes of content.

Hint Stingerloom's column types are database-independent. For example, "boolean" is automatically converted to TINYINT(1) in MySQL and BOOLEAN in PostgreSQL. See the ColumnType Reference at the bottom of this document for the full mapping table.

Setting Column Options ​

@Column() accepts options to control the column's detailed behavior.

Specifying Length ​

Specifies the maximum length of a string column. If omitted, the default length for varchar is 255.

typescript
@Column({ type: "varchar", length: 100 })
sku!: string;

This generates VARCHAR(100) in the DDL. If a user tries to insert a string longer than 100 characters, the database itself will reject the insert with an error.

Allowing NULL ​

By default, all columns are NOT NULL. This means the database will reject any INSERT or UPDATE that tries to set the column to NULL. This is a safety feature -- it prevents accidentally storing missing data.

For columns that may not have a value, set nullable: true.

typescript
@Column({ type: "varchar", length: 255, nullable: true })
bio!: string | null;

This generates VARCHAR(255) (without the NOT NULL constraint) in the DDL. Adding | null to the TypeScript type as well allows natural null checking in your code.

Spell type out whenever the property type is a union. Under strictNullChecks, tsc erases string | null to Object in the decorator metadata -- it looks exactly like Date | null or Record<string, unknown> from the ORM's side -- so @Column({ nullable: true }) bio!: string | null infers text, not VARCHAR(255), and logs an Unknown design:type "Object" warning. See Columns silently become "text".

Column Name Alias ​

Use the name option when you want the property name and the actual DB column name to differ. This is common when you want camelCase in TypeScript but snake_case in the database.

typescript
@Column({ name: "unit_price", type: "float" })
price!: number;
// TypeScript: product.price / DB column: unit_price

In the DDL, the column will be named unit_price. When Stingerloom builds a SELECT query, it maps the unit_price column back to the price property on your TypeScript object.

Default Value ​

Use the default option to set a column's default value. Literal values (string, number, boolean) are used as-is. To use a raw SQL expression (such as a function), wrap it in parentheses.

typescript
@Column({ default: "active" })
status!: string;

@Column({ default: 0 })
retryCount!: number;

@Column({ default: true })
isVisible!: boolean;

@Column({ default: "(CURRENT_TIMESTAMP)" })
createdAt!: Date;

Here is the DDL these produce.

PostgreSQL:

sql
"status" VARCHAR(255) NOT NULL DEFAULT 'active',
"retryCount" INTEGER NOT NULL DEFAULT 0,
"isVisible" BOOLEAN NOT NULL DEFAULT TRUE,
"createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP

MySQL:

sql
`status` VARCHAR(255) NOT NULL DEFAULT 'active',
`retryCount` INT NOT NULL DEFAULT 0,
`isVisible` TINYINT(1) NOT NULL DEFAULT 1,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP

When the value is wrapped in parentheses like "(CURRENT_TIMESTAMP)", Stingerloom emits it as a raw SQL DEFAULT expression in the DDL instead of a quoted string literal. Without the parentheses, "CURRENT_TIMESTAMP" would be treated as the literal string 'CURRENT_TIMESTAMP', not the SQL function.

JSON Column ​

Use the JSON type to store structured data in a single column. This is useful for flexible, schema-less data like user preferences or configuration.

typescript
@Column({ type: "json", nullable: true })
settings!: Record<string, unknown> | null;

PostgreSQL: "settings" JSON (or use "jsonb" for indexed, binary-stored JSON) MySQL: `settings` JSON

The difference between json and jsonb matters in PostgreSQL: jsonb is stored in a decomposed binary format, which is slower to input but significantly faster to query. If you need to query inside the JSON (e.g., WHERE settings->>'theme' = 'dark'), prefer jsonb.

Automatic stringify / parse ​

Stingerloom installs a default round-trip on type: "json" | "jsonb" columns when no explicit transformer is provided:

  • Write: a non-string value is JSON.stringifyed before reaching the driver. Strings pass through unchanged so legacy code that already serializes manually does not double-encode. null and undefined are preserved.
  • Read: a string returned by the driver (mysql2, better-sqlite3) is JSON.parsed. Already-parsed values (PostgreSQL jsonb) pass through. A malformed legacy row logs a single warning and yields the raw string instead of throwing.

This means you can assign plain JS values directly:

typescript
issue.customFields = { priority: "high", labels: ["bug"] };
await em.save(Issue, issue);

const loaded = await em.findOne(Issue, { where: { id } });
loaded.customFields.priority; // "high" — already an object, dialect-agnostic

Supplying any explicit transformer overrides the default for that side; partial transformers (only to or only from) compose with the default for the missing direction.

Migration note — if you previously did JSON.stringify(value) as any before assigning to a JSON column, it still works (strings pass through). Drop the manual stringify when you're ready; both paths produce the same persisted value.

Value Transform ​

You can apply a transform function when mapping values read from the DB to TypeScript objects. This is useful when booleans are stored as numbers, like MySQL's TINYINT(1).

typescript
@Column({ transform: (raw) => raw === 1 })
isActive!: boolean;

When Stingerloom reads a row from the database, it passes the raw isActive value through this function. So the raw number 1 becomes true, and 0 becomes false.

Column Transformers ​

The transform option shown above only works in one direction -- when reading from the database. But in practice, you often need to transform values in both directions. Think of it like a translator who converts between two languages: you need translation going in and coming out.

Consider email addresses. You want every email stored in lowercase to avoid duplicates (Alice@Example.com and alice@example.com should match). Without a transformer, you must remember to call .toLowerCase() before every INSERT and every UPDATE. Forget once, and you have inconsistent data. A column transformer solves this by declaring the transformation once, on the column definition itself.

typescript
@Column({
  type: "varchar",
  transformer: {
    to: (value: string) => value.toLowerCase(),   // before INSERT/UPDATE
    from: (value: string) => value.toUpperCase(),  // after SELECT
  },
})
email: string;

The to function runs automatically before every INSERT and UPDATE. The from function runs automatically after every SELECT, transforming the raw database value back into your application's preferred format.

Here is what happens at the SQL level:

typescript
await em.save(User, { email: "Alice@Example.COM" });
// The transformer.to runs first: "Alice@Example.COM" → "alice@example.com"
// Generated SQL:
// INSERT INTO "user" ("email") VALUES ('alice@example.com')

const user = await em.findOne(User, { where: { id: 1 } });
// DB returns: { email: "alice@example.com" }
// The transformer.from runs: "alice@example.com" → "ALICE@EXAMPLE.COM"
// user.email === "ALICE@EXAMPLE.COM"

Another common use case is encryption. You can encrypt sensitive data before it reaches the database and decrypt it when reading:

typescript
@Column({
  type: "text",
  transformer: {
    to: (value: string) => encrypt(value),   // plaintext → ciphertext
    from: (value: string) => decrypt(value), // ciphertext → plaintext
  },
})
ssn: string;

The database stores the encrypted ciphertext. Your application code works with the plaintext. The transformation is invisible to the rest of your codebase.

Where the transformer applies. Filters and aggregates speak the same representation as the hydrated property, so you compare and sum the value your code holds, not the stored one.

PathBehavior
save / insertMany / upsert family / updateMany datato
Hydration (find, findOne, pluck, query builder getMany)from
Where: equality, eq / ne / not, gt / gte / lt / lte, in / notIn, array shorthand, betweento, per element
Where: like / notLike / ilike / contains / startsWith / endsWith / searchNot applied. Write the pattern in the stored representation
Where: null, isNull, raw sql fragments, QueryDSL (qAlias) conditionsNot applied
sum / avg / min / max (EntityManager and query builder)from on the result
count / existsRow counts, never transformed
Cursor encoding, orderByStored value
increment / decrementRaw arithmetic on the stored value

The where rules cover find, findOne, count, exists, the aggregates, findWithCursor, the criteria of updateMany / delete / softDelete / restore, and the query builder's where(column, value), where(column, operator, value), whereIn and where-object forms. Range operators assume to preserves ordering, and sum / avg assume from is a linear scale such as cents to units. min / max only need ordering. An aggregate whose from does not return a finite number is returned as stored. The built-in JSON serialization is a bind format and does not take part.

Backward compatibility with transform: The legacy read-only transform option still works. If you set both transform and transformer on the same column, transformer.from takes precedence for the read direction. The transform option is deprecated -- use transformer for all new code.

typescript
// Legacy (read-only, deprecated):
@Column({ transform: (raw) => raw === 1 })

// Modern (bidirectional, recommended):
@Column({
  transformer: {
    to: (value: boolean) => value ? 1 : 0,
    from: (raw: number) => raw === 1,
  },
})

PostgreSQL ENUM ​

You can use custom ENUM types in PostgreSQL. An ENUM restricts a column to a fixed set of allowed values, enforced by the database itself.

typescript
@Column({
  type: "enum",
  enumValues: ["draft", "published", "archived"],
  enumName: "post_status",
})
status!: string;

This generates two SQL statements in PostgreSQL:

sql
-- First, create the custom type
CREATE TYPE "post_status" AS ENUM ('draft', 'published', 'archived');

-- Then use it in the column definition
"status" "post_status" NOT NULL

Hint If enumName is omitted, it is automatically generated in the format {tableName}_{columnName}_enum.

synchronize creates the type before the table that references it, and appends values you add to enumValues later (ALTER TYPE ... ADD VALUE). Values you remove stay in the database -- PostgreSQL cannot drop an enum value in place -- so they are reported as a warning rather than applied. See Configuration for the mode-by-mode behavior.

Manual Primary Key (@PrimaryColumn) ​

Sometimes you need a primary key where you specify the value directly rather than using auto-increment. For example, a key-value structured configuration table where the key is a meaningful string.

typescript
// config.entity.ts
import { Entity, PrimaryColumn, Column } from "@stingerloom/orm";

@Entity()
export class Config {
  @PrimaryColumn({ type: "varchar", length: 64 })
  key!: string;

  @Column({ type: "text" })
  value!: string;
}

PostgreSQL DDL:

sql
CREATE TABLE "config" (
  "key" VARCHAR(64) NOT NULL,
  "value" TEXT NOT NULL,
  PRIMARY KEY ("key")
);

@PrimaryColumn() does not apply AUTO_INCREMENT/SERIAL, so you must provide the key value directly when calling save().

typescript
await em.save(Config, { key: "site.title", value: "My Blog" });
// INSERT INTO "config" ("key", "value") VALUES ('site.title', 'My Blog')

Improving Query Performance with Indexes ​

Why Indexes Exist ​

Imagine a library with 10 million books, but no catalog system. To find a book by author, you would need to walk through every shelf and check every book. That is what the database does without an index -- it scans every row in the table (called a "full table scan").

An index is like the library's catalog card system. It maintains a sorted data structure (usually a B-tree, a balanced tree optimized for disk reads) that lets the database jump directly to the matching rows. A query that takes 5 seconds on a million-row table with a full scan might take 5 milliseconds with an index.

The tradeoff: indexes consume disk space and slightly slow down INSERT/UPDATE operations (because the index must be updated too). Add indexes to columns that appear frequently in WHERE, JOIN, or ORDER BY clauses, but do not index every column.

On an existing table, synchronize creates an index you add but never drops or rewrites one: after removing an index from an entity, or changing its columns, drop the old one in a migration -- a unique index left behind keeps rejecting rows. migrate:generate writes no index DDL for an existing table at all. See What the Schema Diff Does Not Compare.

Single-Column Index ​

Adding an Index to frequently searched columns significantly improves query speed. Consider the case of searching for users by email.

typescript
// user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, Index } from "@stingerloom/orm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Index()
  @Column()
  email!: string;
}

Adding @Index() generates the following DDL after the table is created:

sql
-- PostgreSQL
CREATE INDEX "INDEX_user_email" ON "user" ("email");

-- MySQL
CREATE INDEX `INDEX_user_email` ON `user` (`email`);

Now, when the ORM executes:

sql
SELECT * FROM "user" WHERE "email" = 'alice@example.com';

Instead of scanning every row, the database traverses the B-tree index to find the matching row in O(log n) time.

Composite Index (@Index on class) ​

@Index() can also be used as a class-level decorator to create a composite (multi-column) non-unique index. This is useful for queries that filter on multiple columns simultaneously.

typescript
// order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, Index } from "@stingerloom/orm";

@Index(["tenantId", "status"])
@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ type: "int" })
  tenantId!: number;

  @Column({ type: "varchar", length: 50 })
  status!: string;
}

This generates:

sql
-- PostgreSQL
CREATE INDEX "idx_order_tenantId_status" ON "order" ("tenantId", "status");

A composite index works like a phone book sorted by last name, then first name. The database can use it for:

  • WHERE tenantId = 5 (uses the leftmost column)
  • WHERE tenantId = 5 AND status = 'pending' (uses both columns -- most efficient)

But it cannot efficiently use it for WHERE status = 'pending' alone (skipping the leftmost column is like trying to find everyone named "John" in a phone book sorted by last name first).

You can also specify a custom index name.

typescript
@Index(["tenantId", "status"], "idx_custom_name")

Hint Property-level @Index() creates a single-column index. Class-level @Index(columns) creates a composite index. Both can be used on the same entity.

Composite Unique Index (@UniqueIndex) ​

Sometimes the combination of multiple columns must be unique. For example, when a slug only needs to be unique within the same category.

typescript
// post.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, UniqueIndex } from "@stingerloom/orm";

@UniqueIndex(["categoryId", "slug"])
@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  categoryId!: number;

  @Column()
  slug!: string;
}

This generates:

sql
-- PostgreSQL
CREATE UNIQUE INDEX "UQ_post_categoryId_slug" ON "post" ("categoryId", "slug");

With this unique index, the following data is allowed:

categoryIdslug
1hello-world
2hello-world

But this would be rejected by the database:

categoryIdslug
1hello-world
1hello-world

You can also specify the index name directly.

typescript
@UniqueIndex(["categoryId", "slug"], { name: "uq_post_category_slug" })

Hint @UniqueIndex and @Index(columns) are both class-level decorators. Property-level @Index() (no arguments) is placed on individual properties.

Advanced Index Options ​

A basic B-tree index on a column is the most common case, but real-world databases need more specialized indexes. Think of it like tools in a workshop: a hammer works for most nails, but sometimes you need a screwdriver, a wrench, or a saw. Similarly, different query patterns call for different index strategies.

Partial indexes save disk space and improve performance by indexing only a subset of rows. If 90% of your users are active and you almost never query inactive users, why index all of them?

Expression indexes let you index a computed value. Without one, WHERE LOWER(email) = 'alice@example.com' cannot use a regular index on the email column because the index stores the original mixed-case values.

GIN indexes enable fast searches inside JSONB data and full-text search. BRIN indexes are extremely compact indexes for time-series data where values naturally correlate with physical row order.

The @Index class-level decorator accepts an AdvancedIndexOptions object as its second argument to configure all of these.

Partial Index ​

A partial index includes only rows that match a WHERE condition. This is useful for tables with soft delete, where you almost always query non-deleted rows.

typescript
@Entity()
@Index(["email"], { where: "deleted_at IS NULL" })
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  email!: string;

  @DeletedAt()
  deletedAt!: Date | null;
}

PostgreSQL DDL:

sql
CREATE INDEX "idx_user_email" ON "user" ("email") WHERE deleted_at IS NULL;

This index is smaller than a full index because it excludes soft-deleted rows. Queries with WHERE email = '...' AND deleted_at IS NULL use this index directly.

Note: Partial indexes are supported on PostgreSQL and SQLite. MySQL does not support the WHERE clause on indexes.

Expression Index ​

An expression index indexes the result of an expression rather than a raw column value. This is essential for case-insensitive email lookups.

typescript
@Entity()
@Index([], { expression: "LOWER(email)" })
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  email!: string;
}

PostgreSQL DDL:

sql
CREATE INDEX "idx_user_lower_email" ON "user" ((LOWER(email)));

Now the query WHERE LOWER(email) = 'alice@example.com' uses this index instead of doing a full table scan. Notice that the column array is empty ([]) because the expression replaces the column list.

Index Type (USING) ​

By default, databases create B-tree indexes, which are optimal for equality and range queries. But other index types exist for specialized use cases.

typescript
@Entity()
@Index(["metadata"], { using: "gin" })
export class Product {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ type: "jsonb" })
  metadata!: Record<string, unknown>;
}

PostgreSQL DDL:

sql
CREATE INDEX "idx_product_metadata" ON "product" USING gin ("metadata");

This GIN (Generalized Inverted Index) enables fast @> containment queries on JSONB data:

sql
-- Find products where metadata contains {"color": "red"}
SELECT * FROM "product" WHERE "metadata" @> '{"color": "red"}';
-- Uses the GIN index instead of scanning every row

Available index types:

TypeBest ForPostgreSQLMySQL
btreeEquality, range, sorting (default)YesYes
hashEquality only (no range)YesYes
ginJSONB, arrays, full-text searchYesNo
gistGeometric, full-text, range typesYesNo
brinLarge time-series tablesYesNo

Note: MySQL only supports btree and hash. If you specify gin, gist, or brin and run against MySQL, the index creation will fail.

Covering Index (INCLUDE) ​

A covering index stores additional columns alongside the indexed columns so that the database can answer a query entirely from the index, without reading the main table. This is called an "index-only scan."

typescript
@Entity()
@Index(["email"], { include: ["name"] })
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  email!: string;

  @Column()
  name!: string;
}

PostgreSQL DDL:

sql
CREATE INDEX "idx_user_email" ON "user" ("email") INCLUDE ("name");

Now a query like SELECT name FROM "user" WHERE email = 'alice@example.com' can be served entirely from the index without touching the heap (main table storage). The INCLUDE columns are stored in the index leaf pages but are not part of the search key.

Note: Covering indexes with INCLUDE are supported on PostgreSQL 11+. MySQL does not support the INCLUDE clause.

Combining Options ​

Advanced index options can be combined. For example, a partial covering index:

typescript
@Index(["email"], {
  where: "deleted_at IS NULL",
  include: ["name"],
  name: "idx_active_user_email",
})

PostgreSQL DDL:

sql
CREATE INDEX "idx_active_user_email" ON "user" ("email") INCLUDE ("name") WHERE deleted_at IS NULL;

This creates a compact, high-performance index that covers only active users and includes the name column for index-only scans.

JSON Path Indexes (@JsonIndex) ​

Why @JsonIndex Exists ​

A jsonb column without an index requires a sequential scan for every query — even WHERE data @> '{"tags":["typescript"]}' on a million-row table. PostgreSQL supports expression indexes over JSON paths (CREATE INDEX ... USING GIN ((data->'tags') jsonb_path_ops)), but writing them by hand ties your migration SQL to a specific path and opclass that drifts from the entity.

@JsonIndex declares the index on the property so the schema generator emits the correct DDL, and QueryDSL path operations (u.profile.tags.contains(...)) line up with a path your DB can actually use.

How It Works ​

typescript
import { Entity, PrimaryGeneratedColumn, Column, JsonIndex } from "@stingerloom/orm";

@Entity()
class User {
  @PrimaryGeneratedColumn()
  id!: number;

  // GIN index on a sub-path for @> / ? queries
  @Column({ type: "jsonb" })
  @JsonIndex({ path: "tags", using: "gin", opclass: "jsonb_path_ops" })
  profile!: { tags: string[]; contact: { email: string } };
}

PostgreSQL DDL:

sql
CREATE INDEX "idx_user_profile_tags_gin"
  ON "user" USING gin ((profile -> 'tags') jsonb_path_ops);

Option Reference ​

OptionTypeDefaultDescription
pathstring?(whole column)Dot-bracket path inside the column: "tags", "contact.email", "tags[0]". Omit to index the whole column.
using"gin" | "btree""gin"gin for containment (@>) / key-existence (?); btree for leaf scalar paths (equality / range).
opclass"jsonb_ops" | "jsonb_path_ops""jsonb_ops"jsonb_path_ops is smaller & faster when the index only serves @>. Ignored for btree.
wherestring?—Partial-index WHERE clause (raw SQL). The caller is responsible for identifier wrapping and parameter safety.
namestring?autoOverride the index name. Auto-generated via NamingStrategy.jsonIndexName().

Patterns ​

Whole-column GIN — covers every @> / ? / ?& query under the column, at the cost of a larger index:

typescript
@Column({ type: "jsonb" })
@JsonIndex({ using: "gin" })
profile!: UserProfile;

Leaf btree on a text path — for equality/range on a single scalar leaf:

typescript
@Column({ type: "jsonb" })
@JsonIndex({ path: "contact.email", using: "btree" })
profile!: UserProfile;

Emits CREATE INDEX ... ON "user" (((profile #>> '{contact,email}'))).

Partial index — skip rows that will never match:

typescript
@Column({ type: "jsonb" })
@JsonIndex({
  path: "tags",
  using: "gin",
  opclass: "jsonb_path_ops",
  where: `"deleted_at" IS NULL`,
})
profile!: UserProfile;

Dialect Behavior ​

DriverBehavior
PostgreSQLEmits CREATE INDEX ... USING gin ((col -> 'path') [jsonb_path_ops]) for GIN; ((col #>> '{path}')) for btree.
MySQLNo DDL is emitted. MySQL 8 supports functional JSON indexes only via virtual generated columns — a structural schema change beyond an index declaration. A warning is logged at DDL generation. Create the generated column + index manually if you need one.
SQLiteNo DDL is emitted. SQLite has no GIN equivalent.

Pairing with QueryDSL ​

QueryDSL's JSON operators compile to the same path expression your index was built on, so the planner can use it:

typescript
const users = await qb
  .qWhere((u) => u.profile.tags.contains("typescript"))
  .getMany();
// PostgreSQL: WHERE profile -> 'tags' @> '"typescript"'::jsonb
// Uses: idx_user_profile_tags_gin

See Query Builder — JSON Path Operators for the full operator catalog.

Optimistic Locking (@Version) ​

Why Optimistic Locking Exists ​

Imagine two customer service agents opening the same order at 10:00 AM. Agent A changes the status to "shipped" at 10:01. Agent B, still looking at the old data, changes the status to "refunded" at 10:02. Agent B's update silently overwrites Agent A's change. This is the lost update problem.

One solution is pessimistic locking -- lock the row so nobody else can touch it. But this is slow and can cause deadlocks. A better approach for most applications is optimistic locking: assume conflicts are rare, but detect them when they happen.

How It Works ​

Using the @Version() decorator adds a version counter to your entity.

typescript
// order.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, Version } from "@stingerloom/orm";

@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  status!: string;

  @Version()
  version!: number;
}

DDL generated:

sql
-- PostgreSQL
CREATE TABLE "order" (
  "id" SERIAL PRIMARY KEY,
  "status" VARCHAR(255) NOT NULL,
  "version" INTEGER NOT NULL
);

Here is exactly what happens at the SQL level.

On INSERT, the ORM automatically sets version = 1:

sql
INSERT INTO "order" ("status", "version") VALUES ('pending', 1);

On UPDATE, the ORM adds WHERE version = N and increments the version:

sql
-- Agent A reads the order: { id: 1, status: 'pending', version: 1 }
-- Agent A updates it:
UPDATE "order"
SET "status" = 'shipped', "version" = "version" + 1
WHERE "id" = 1 AND "version" = 1;
-- This succeeds (1 row affected). version is now 2.

-- Agent B also read version 1, tries to update:
UPDATE "order"
SET "status" = 'refunded', "version" = "version" + 1
WHERE "id" = 1 AND "version" = 1;
-- This fails (0 rows affected) because version is now 2.
-- Stingerloom throws OptimisticLockError.

When the UPDATE affects 0 rows, it means someone else modified the data first. Stingerloom detects this and throws an OptimisticLockError, preventing the silent data loss.

upsert() and batchUpsert() increment the version too when they update a conflicting row (version = stored version + 1), but they do not check it -- an upsert is last-write-wins and never throws OptimisticLockError. A version in the upsert payload is used only when the row is inserted. See Managed columns on conflict.

Hint Optimistic locking is suitable when conflicts are rare but data integrity is important (e.g., order status changes, inventory management). For high-contention scenarios where conflicts happen on nearly every request, consider pessimistic locking with SELECT ... FOR UPDATE instead.

Soft Delete (@DeletedAt) ​

Why Soft Delete Exists ​

Hard delete (DELETE FROM ...) permanently removes data. Once deleted, it is gone forever (unless you have backups). In many real-world applications, you want to:

  1. Let users "undo" a deletion (like a trash can in a file system)
  2. Keep audit trails for compliance
  3. Preserve data for analytics while hiding it from the application

Soft delete solves this by never actually deleting the row. Instead, it marks the row with a timestamp indicating when it was "deleted."

How It Works ​

typescript
// post.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, DeletedAt } from "@stingerloom/orm";

@Entity()
export class Post {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @DeletedAt()
  deletedAt!: Date | null;
}

DDL generated:

sql
-- PostgreSQL
CREATE TABLE "post" (
  "id" SERIAL PRIMARY KEY,
  "title" VARCHAR(255) NOT NULL,
  "deletedAt" TIMESTAMP          -- nullable (NULL means "not deleted")
);

Adding the @DeletedAt() decorator changes three things at the SQL level.

First, calling em.softDelete(Post, { id: 1 }) executes an UPDATE, not a DELETE:

sql
-- Instead of: DELETE FROM "post" WHERE "id" = 1
-- Stingerloom runs:
UPDATE "post" SET "deletedAt" = NOW() WHERE "id" = 1;

Second, em.find(Post) automatically adds a WHERE filter to exclude soft-deleted rows:

sql
-- Your code:
await em.find(Post);

-- Generated SQL:
SELECT * FROM "post" WHERE "deletedAt" IS NULL;

Every query automatically ignores soft-deleted rows. You do not need to remember to add this condition yourself.

Third, to include deleted data in the query, use the { withDeleted: true } option:

sql
-- Your code:
await em.find(Post, { withDeleted: true });

-- Generated SQL (no filter):
SELECT * FROM "post";

And to restore a soft-deleted row:

sql
-- Your code:
await em.restore(Post, { id: 1 });

-- Generated SQL:
UPDATE "post" SET "deletedAt" = NULL WHERE "id" = 1;

Here is the complete lifecycle in code:

typescript
await em.softDelete(Post, { id: 1 });                      // soft delete
const posts = await em.find(Post);                         // excludes deleted
const all = await em.find(Post, { withDeleted: true });    // includes deleted
await em.restore(Post, { id: 1 });                         // restore

upsert() / batchUpsert() restore a soft-deleted row they conflict with: unless the payload sets deletedAt, the conflict branch writes deletedAt = NULL. See Managed columns on conflict for how unique indexes decide whether a trashed row conflicts.

Automatic Timestamps (@CreateTimestamp / @UpdateTimestamp) ​

Why Automatic Timestamps Exist ​

Almost every table in a production application needs to answer two questions: "When was this row created?" and "When was it last modified?" These timestamps are essential for debugging ("when did this data change?"), sorting ("show newest first"), and auditing.

You could set these manually every time you call save(), but that is error-prone -- forget once and you have bad data. The @CreateTimestamp and @UpdateTimestamp decorators handle this automatically.

How It Works ​

typescript
// article.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  CreateTimestamp, UpdateTimestamp,
} from "@stingerloom/orm";

@Entity()
export class Article {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @CreateTimestamp()
  createdAt!: Date;

  @UpdateTimestamp()
  updatedAt!: Date;
}

DDL generated:

sql
-- PostgreSQL
CREATE TABLE "article" (
  "id" SERIAL PRIMARY KEY,
  "title" VARCHAR(255) NOT NULL,
  "createdAt" TIMESTAMP NOT NULL,
  "updatedAt" TIMESTAMP NOT NULL
);

-- MySQL
CREATE TABLE `article` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `title` VARCHAR(255) NOT NULL,
  `createdAt` DATETIME NOT NULL,
  `updatedAt` DATETIME NOT NULL,
  PRIMARY KEY (`id`)
);

Here is exactly when the ORM sets each value.

On INSERT, both createdAt and updatedAt are set to the current time:

sql
-- Your code:
await em.save(Article, { title: "Hello World" });

-- Generated SQL (PostgreSQL):
INSERT INTO "article" ("title", "createdAt", "updatedAt")
VALUES ('Hello World', '2026-03-22 10:30:00', '2026-03-22 10:30:00');

The ORM generates the timestamp in application code (new Date()), not with a SQL function like NOW(). This means both columns get exactly the same value, down to the millisecond.

On UPDATE, only updatedAt is refreshed. createdAt is left untouched:

sql
-- Your code:
await em.save(Article, { id: 1, title: "Hello World (updated)" });

-- Generated SQL (PostgreSQL):
UPDATE "article"
SET "title" = 'Hello World (updated)', "updatedAt" = '2026-03-22 11:45:00'
WHERE "id" = 1;
-- Notice: createdAt is NOT in the SET clause

Both decorators create a DATETIME (MySQL) / TIMESTAMP (PostgreSQL) NOT NULL column by default. For timezone-aware timestamps pass the type -- @CreateTimestamp({ type: "timestamptz" }) / @UpdateTimestamp({ type: "timestamptz" }) -- and every write path stamps them the same way.

Hint If a value is explicitly provided for a @CreateTimestamp or @UpdateTimestamp column in the save() call, the provided value is used instead of the auto-generated one. This is useful for data migration scenarios where you want to preserve original timestamps. upsert() / batchUpsert() use a provided @CreateTimestamp value only when the row is inserted, and a provided @UpdateTimestamp value on both branches.

Computed Columns (@ComputedColumn) ​

Some column values are always derived from other columns. A person's full name is always first_name + ' ' + last_name. An order line's total is always price * quantity. You could calculate these in your application code, but then every query, every service, and every report must repeat the same formula. Forget once, and the value is wrong. It is like having a spreadsheet where instead of using a formula, you manually type the total in every cell -- eventually someone makes a mistake.

A computed column (also called a "generated column") moves this formula into the database itself. The database guarantees the value is always correct, just like a spreadsheet formula that recalculates automatically.

typescript
// order-line.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column, ComputedColumn,
} from "@stingerloom/orm";

@Entity()
export class OrderLine {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ type: "float" })
  price!: number;

  @Column({ type: "int" })
  quantity!: number;

  @ComputedColumn({
    expression: "price * quantity",
    stored: true,
    type: "float",
  })
  total!: number;
}

PostgreSQL DDL:

sql
CREATE TABLE "orderLine" (
  "id" SERIAL PRIMARY KEY,
  "price" REAL NOT NULL,
  "quantity" INTEGER NOT NULL,
  "total" REAL GENERATED ALWAYS AS (price * quantity) STORED
);

MySQL DDL:

sql
CREATE TABLE `orderLine` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `price` FLOAT NOT NULL,
  `quantity` INT NOT NULL,
  `total` FLOAT GENERATED ALWAYS AS (price * quantity) STORED,
  PRIMARY KEY (`id`)
);

The GENERATED ALWAYS AS (expression) clause tells the database to compute this value automatically. You never set it yourself -- in fact, the database will reject any INSERT or UPDATE that tries to write to a generated column.

Stingerloom automatically excludes computed columns from INSERT and UPDATE statements. When you call em.save(), the ORM knows not to include total in the SQL:

typescript
await em.save(OrderLine, { price: 29.99, quantity: 3 });
// Generated SQL (PostgreSQL):
// INSERT INTO "orderLine" ("price", "quantity") VALUES (29.99, 3)
// Note: "total" is NOT in the INSERT -- the DB computes it as 89.97

Here is another common example -- concatenating first and last names:

typescript
@Entity()
export class Person {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  firstName!: string;

  @Column()
  lastName!: string;

  @ComputedColumn({
    expression: "first_name || ' ' || last_name",
    stored: true,
    type: "varchar",
    length: 511,
  })
  fullName!: string;
}

PostgreSQL DDL:

sql
"fullName" VARCHAR(511) GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED

STORED vs VIRTUAL ​

The stored option controls how the database handles the computed value:

ModestoredBehaviorDisk SpaceIndexable
STOREDtrueComputed on INSERT/UPDATE, saved to diskUses spaceYes
VIRTUALfalse (default)Computed on every SELECT readNo extra spaceLimited

STORED columns are computed once when the row is written and persisted on disk like a regular column. They can be indexed, which makes them suitable for columns you search or sort by (like fullName).

VIRTUAL columns are computed every time the row is read. They use no extra disk space but add CPU overhead on every SELECT. Use virtual columns for values you display but never search by.

typescript
// VIRTUAL -- computed on every read, no disk usage
@ComputedColumn({
  expression: "price * quantity",
  stored: false,  // this is the default
})
total!: number;

// STORED -- computed once on write, persisted, indexable
@ComputedColumn({
  expression: "price * quantity",
  stored: true,
})
total!: number;

Hint Computed columns are supported on PostgreSQL 12+, MySQL 5.7+, and SQLite 3.31+. The @ComputedColumn decorator accepts optional type, length, and nullable options. If type is omitted, the column is created as TEXT.

Warning PostgreSQL supports only STORED generated columns. On PostgreSQL the column is always created as STORED -- an explicit stored: false request logs a warning and is coerced.

Schema synchronization behavior ​

Computed columns participate in both schema workflows:

  • synchronize: true (and "safe") -- the generated column is part of the CREATE TABLE statement for new tables, and is added to an existing table with ALTER TABLE ... ADD COLUMN (ADD semantics, so "safe" mode applies it too; "dry-run" only logs the DDL).
  • migrate:generate -- the same definition is emitted into the migration file, with a matching DROP COLUMN in down().

Both paths render the definition through the same builder, so they can never diverge. An existing generated column is never compared or dropped by synchronize: if you change the expression, write a migration that drops and re-adds the column -- expression drift is not auto-detected.

One dialect caveat: adding a generated column to an existing table via ALTER is rejected by some engines for STORED columns (notably older SQLite). The DDL error is reported through the normal synchronize error handling (continueOnError) rather than being silently skipped -- prefer stored: false when you expect the column to be added to live tables on SQLite.

Dialect-portable expressions (builder form) ​

A literal expression string is embedded into the DDL verbatim, so it must already be valid SQL for the target database. That is fine for portable arithmetic like price * quantity, but date math diverges sharply between engines -- MySQL spells "hours between two timestamps" TIMESTAMPDIFF(HOUR, a, b), while PostgreSQL needs EXTRACT(EPOCH FROM (b - a)) / 3600. Hard-coding one locks the entity to a single database, and branching on process.env.DB_TYPE couples the entity to runtime configuration -- it also breaks the moment one process connects to two databases at once.

Pass a builder function instead of a string. It receives an expression context e and returns a portable expression that Stingerloom compiles to dialect-correct SQL once per connection:

typescript
@Entity()
export class Issue {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column({ type: "datetime", nullable: true })
  createdAt!: Date;

  @Column({ type: "datetime", nullable: true })
  completedAt!: Date | null;

  // One definition -> correct DDL on MySQL, PostgreSQL, and SQLite.
  @ComputedColumn({
    expression: (e) =>
      e.dateDiff(e.col("completed_at"), e.col("created_at"), "hour"),
    type: "int",
    nullable: true,
  })
  cycleTimeHours?: number;
}

MySQL DDL:

sql
`cycleTimeHours` INT GENERATED ALWAYS AS (TIMESTAMPDIFF(HOUR, `created_at`, `completed_at`)) VIRTUAL

PostgreSQL DDL:

sql
"cycleTimeHours" INTEGER GENERATED ALWAYS AS (EXTRACT(EPOCH FROM ("completed_at" - "created_at")) / 3600) STORED

The context e exposes the full Expressions DSL -- e.iff, e.caseBuilder, e.coalesce, e.dateDiff, and the rest -- plus e.col(name) to reference a sibling column by its database column name. Wrap branching logic in e.iff for a portable CASE:

typescript
@ComputedColumn({
  expression: (e) =>
    e.iff(
      e.col("deleted_at").isNull().and(e.col("completed_at").isNotNull()),
      e.dateDiff(e.col("completed_at"), e.col("created_at"), "hour"),
      null,
    ),
  type: "int",
  nullable: true,
})
cycleTimeHours?: number;

Generated-column expressions cannot carry bind parameters, so any constant in the builder (the null above, a numeric threshold, a string label) is inlined as a SQL literal. The literal-string form keeps working unchanged -- reach for the builder whenever an expression would otherwise need per-dialect SQL.

Lifecycle Hooks ​

Why Lifecycle Hooks Exist ​

Sometimes you need custom logic to run at specific moments during an entity's lifecycle -- before it is inserted, after it is updated, before it is deleted. For example, you might want to generate a URL slug from the title before saving, or send a notification after an entity is created.

Lifecycle hooks let you define this logic directly on the entity class, so it runs automatically every time the entity goes through that lifecycle event.

How It Works ​

typescript
// article.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  BeforeInsert, BeforeUpdate,
} from "@stingerloom/orm";

@Entity()
export class Article {
  @PrimaryGeneratedColumn()
  id!: number;

  @Column()
  title!: string;

  @Column({ type: "varchar", length: 255, nullable: true })
  slug!: string | null;

  @BeforeInsert()
  generateSlug() {
    if (!this.slug) {
      this.slug = this.title.toLowerCase().replace(/\s+/g, "-");
    }
  }
}

Methods decorated with @BeforeInsert() are automatically called just before the INSERT SQL is executed. In the example above, when you call em.save(Article, { title: "Hello World" }), the ORM first calls generateSlug() which sets this.slug = "hello-world", and then the INSERT includes the slug:

sql
INSERT INTO "article" ("title", "slug") VALUES ('Hello World', 'hello-world');

There are 6 available lifecycle hooks in total.

DecoratorExecution TimingWhen to Use
@BeforeInsert()Just before INSERT SQL executesDefault values, slug generation, data normalization
@AfterInsert()After INSERT completesLogging, sending notifications, cache invalidation
@BeforeUpdate()Just before UPDATE SQL executesUpdating computed fields, validation
@AfterUpdate()After UPDATE completesRecording change history, triggering webhooks
@BeforeDelete()Just before DELETE SQL executesCleanup before deletion, permission checks
@AfterDelete()After DELETE completesCleaning up related resources, logging

The execution order for a save() call that inserts a new entity is:

  1. @BeforeInsert() hooks run (you can mutate the entity here)
  2. INSERT INTO ... SQL executes
  3. @AfterInsert() hooks run (the entity now has its auto-generated ID)

Hint In "After" hooks, the DB operation has already completed, so modifying data will not be reflected in the DB. Use them for side effects like logging or external notifications.

Validation ​

Why Validation Exists ​

The database has some built-in constraints (NOT NULL, VARCHAR length, UNIQUE), but they produce cryptic error messages and only catch problems after a network round-trip. Application-level validation catches mistakes early, before the SQL query is even built, and returns clear error messages.

How It Works ​

Use validation decorators when you want to automatically verify that data is correct when save() is called. If validation fails, a ValidationError is thrown to prevent invalid data from entering the DB.

typescript
// member.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column,
  NotNull, MinLength, MaxLength, Min, Max,
} from "@stingerloom/orm";

@Entity()
export class Member {
  @PrimaryGeneratedColumn()
  id!: number;

  @NotNull()
  @MinLength(2)
  @MaxLength(50)
  @Column()
  name!: string;

  @Min(0)
  @Max(150)
  @Column()
  age!: number;
}

Each decorator's role is self-explanatory.

  • @NotNull() -- Error if null or undefined
  • @MinLength(n) / @MaxLength(n) -- String length validation
  • @Min(n) / @Max(n) -- Numeric range validation

Calling save() with invalid data will throw an error before the DB query is executed. No SQL is sent to the database at all.

typescript
await em.save(Member, { name: "A", age: -1 });
// ValidationError: name must be at least 2 characters long
// (no INSERT was attempted)

This is different from a database constraint error. A NOT NULL constraint violation returns a database-specific error after the query is sent. A @NotNull() validation error is caught in your application code before any network call.

Complete Real-World Example ​

Here is a blog user entity that combines all the features covered so far.

typescript
// user.entity.ts
import {
  Entity, PrimaryGeneratedColumn, Column, Index, Version,
  DeletedAt, CreateTimestamp, UpdateTimestamp, AfterInsert,
  NotNull, MinLength, MaxLength,
} from "@stingerloom/orm";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number;

  @NotNull()
  @MinLength(2)
  @MaxLength(50)
  @Column()
  name!: string;

  @NotNull()
  @Index()
  @Column({ type: "varchar", length: 255 })
  email!: string;

  @Column({ type: "varchar", length: 20, nullable: true })
  phone!: string | null;

  @Column({ type: "boolean" })
  isActive!: boolean;

  @Column({ type: "json", nullable: true })
  profile!: Record<string, unknown> | null;

  @Version()
  version!: number;

  @DeletedAt()
  deletedAt!: Date | null;

  @CreateTimestamp()
  createdAt!: Date;

  @UpdateTimestamp()
  updatedAt!: Date;

  @AfterInsert()
  log() {
    console.log(`User #${this.id} created`);
  }
}

PostgreSQL DDL:

sql
CREATE TABLE "user" (
  "id" SERIAL PRIMARY KEY,
  "name" VARCHAR(255) NOT NULL,
  "email" VARCHAR(255) NOT NULL,
  "phone" VARCHAR(20),
  "isActive" BOOLEAN NOT NULL,
  "profile" JSON,
  "version" INTEGER NOT NULL,
  "deletedAt" TIMESTAMP,
  "createdAt" TIMESTAMP NOT NULL,
  "updatedAt" TIMESTAMP NOT NULL
);

CREATE INDEX "INDEX_user_email" ON "user" ("email");

Here is the SQL that Stingerloom generates for common operations on this entity.

INSERT (new user):

sql
INSERT INTO "user" ("name", "email", "phone", "isActive", "profile", "version", "createdAt", "updatedAt")
VALUES ('Alice', 'alice@example.com', NULL, TRUE, NULL, 1, '2026-03-22 10:00:00', '2026-03-22 10:00:00')
RETURNING *;
-- Note: version is auto-set to 1, both timestamps are auto-set
-- After INSERT, the @AfterInsert hook runs: console.log("User #1 created")

UPDATE (change name):

sql
UPDATE "user"
SET "name" = 'Alice Smith', "updatedAt" = '2026-03-22 11:00:00', "version" = "version" + 1
WHERE "id" = 1 AND "version" = 1;
-- Note: createdAt is NOT updated, version increments, updatedAt refreshes

Soft delete:

sql
UPDATE "user" SET "deletedAt" = NOW() WHERE "id" = 1;

Find (auto-excludes soft-deleted):

sql
SELECT * FROM "user" WHERE "deletedAt" IS NULL;

This single entity includes auto-increment PK, validation, index, JSON column, optimistic locking, Soft Delete, automatic timestamps, and logging.

ColumnType Reference ​

TypeScript Type Auto-Inference ​

When type is omitted in @Column(), it is automatically inferred from the TypeScript type.

TypeScript TypeColumnTypeDefault Lengthnullable
Stringvarchar255false
Numberint11false
Booleanboolean1false
Datedatetime0false
Bufferblob0true
BigIntbigint (bigintMode: "bigint")0false
Array (string[], Tag[], tuples)json0true
Objecttext0true
Othertext0true

An array property is stored as JSON: @Column() tags!: string[] becomes a json column, which is JSON on MySQL, JSON on PostgreSQL and TEXT on SQLite, and the value round-trips as a real array. tsc emits Array only for array and tuple types, so nothing else lands on that row. The one exception is an array property whose transformer has a to(): that write transformer owns the stored shape (["a", "b"] -> "a,b"), so the column stays text and the decorator logs a warning asking for an explicit type. A read-only transform decides nothing about storage -- transformer.from on its own, or the deprecated transform -- so the column is json like any other array property: the ORM serializes the array on write, and your from receives that JSON text on read. Declare @Column({ type: "text" }) when you need the old stored shape.

Object is the opposite case -- it means "tsc could not tell us", not "this is an object". Under strictNullChecks every union erases to Object (string | null, Date | null, number | undefined), and transpile-only builds emit it for enums imported from another module, so the fallback stays text with a warning. Declare type explicitly there: @Column({ type: "json" }) for an object value, @Column({ type: "varchar" }) / "int" / "datetime" for a nullable scalar.

DDL drift when upgrading

An existing @Column() tags!: string[] that has been running against MySQL or PostgreSQL has a TEXT column in the database and now diffs as json. synchronize: true will attempt ALTER ... TYPE JSON, which PostgreSQL refuses without a USING clause and MySQL rejects when any row holds non-JSON text. Either add @Column({ type: "text" }) to keep the current column, or migrate the data and the column type deliberately. SQLite is unaffected -- both types are TEXT.

Indexes move with the column. @Index() or @UniqueIndex(["tags"]) over an untyped array property now targets a json column, and PostgreSQL has no default btree operator class for json, so CREATE INDEX ... ON "post" ("tags") fails with data type json has no default operator class for access method "btree" -- on a brand-new database as well, not only on an upgrade. With the default continueOnError: true synchronize reports it as a warning and leaves the index uncreated; continueOnError: false stops the boot. Declare @Column({ type: "text" }) on an indexed array property, or index an expression instead.

DB Mapping by ColumnType ​

This table shows how each abstract ColumnType is translated to a concrete database type by each driver's castType() method.

ColumnTypeMySQL/MariaDBPostgreSQLSQLite
varcharVARCHAR(n)VARCHAR(n)TEXT
int / numberINTINTEGERINTEGER
floatFLOATREALREAL
doubleDOUBLEDOUBLE PRECISIONREAL
bigintBIGINTBIGINTBIGINT (INTEGER affinity)
booleanTINYINT(1)BOOLEANINTEGER
datetimeDATETIMETIMESTAMPTEXT
timestampTIMESTAMPTIMESTAMPTEXT
timestamptzDATETIMETIMESTAMPTZTEXT
dateDATEDATETEXT
textTEXTTEXTTEXT
longtextLONGTEXTTEXTTEXT
blobBLOBBYTEABLOB
jsonJSONJSONTEXT
jsonbJSONJSONBTEXT
enumENUM(custom ENUM)TEXT
arrayJSONelement[] (default TEXT[])TEXT

On PostgreSQL, type: "array" produces a native array column. The element type comes from arrayElementType (default "text"):

typescript
@Column({ type: "array", nullable: true })
tags!: string[] | null; // TEXT[]

@Column({ type: "array", arrayElementType: "int", nullable: true })
scores!: number[] | null; // INTEGER[]

Plain JS arrays round-trip through the pg driver's native array serialization. MySQL stores array columns as JSON and SQLite as TEXT, where arrayElementType is ignored; on those two drivers the ORM serializes the value with JSON.stringify on write and parses it back on read, so ["x", "y"] comes out of find() as ["x", "y"] there as well. Note that schema diffing reads every PostgreSQL array column simply as ARRAY, so it cannot detect element-type changes. Entity generation reads the element type (integer[] → arrayElementType: "int").

If the column already holds rows written by an earlier version on MySQL or SQLite, they are bare element text (x), not JSON. Reading one back logs a single Failed to JSON.parse warning for the column and yields the raw string; convert the rows once with an UPDATE, or keep the old shape with a transformer.

bigint Columns and bigintMode ​

A JS number holds integers exactly only up to 2^53 (Number.MAX_SAFE_INTEGER, about 9.007e15), while a database BIGINT goes up to 2^63. The drivers deliver bigint values losslessly: pg returns them as strings, and mysql2 / better-sqlite3 return a number while the value fits and a decimal string once it does not. bigintMode decides what the entity property holds, on every driver:

bigintModeProperty typeBeyond ±2^53
"number" (default)numberthrows OrmError with code BIGINT_PRECISION_LOSS instead of rounding
"string"string (decimal digits)lossless, JSON-safe
"bigint"bigint (native BigInt)lossless; JSON.stringify needs a replacer
typescript
@Entity()
export class Account {
  @PrimaryGeneratedColumn({ type: "bigint" })
  id!: number; // default mode: a number, like every other auto-increment key

  @Column({ type: "bigint", bigintMode: "string" })
  balanceCents!: string; // "9007199254740993"

  @Column({ type: "bigint", bigintMode: "bigint" })
  snowflakeId!: bigint; // 9007199254740993n

  @Column()
  version!: bigint; // a `bigint` property type infers type: "bigint" + bigintMode: "bigint"
}

The mode applies to reads (find*, findWithCursor, stream, RETURNING rows) and to the primary keys saveMany() / insertManyAndReturn() derive for a batch. Writes accept a number, a digit string or a BigInt in any mode — every driver binds a BigInt parameter natively, so where: { snowflakeId: 9007199254740993n } works without a cast. Aggregates keep their number return type: sum() / min() / max() (and the SelectQueryBuilder equivalents) throw BIGINT_PRECISION_LOSS rather than round an integer result beyond ±2^53; read such a value through getRawOne() with coerce: { total: "bigint" } instead. Cursor pagination encodes BigInt order and key values, and logging: true renders BigInt parameters as digits.

On SQLite, bigint columns are declared BIGINT (the same INTEGER affinity and storage as before) so the connector can tell which statements need lossless integer reads; an existing table whose column is declared INTEGER keeps working and is not reported as a type change, but its values beyond ±2^53 stay rounded until the column is recreated as BIGINT. Auto-increment primary keys remain INTEGER PRIMARY KEY, the only spelling that aliases SQLite's rowid.

@Column Full Options ​

OptionTypeDescription
namestringDB column name (defaults to property name)
typeColumnTypeColumn type (auto-inferred if omitted)
lengthnumberColumn length
nullablebooleanAllow NULL (default: false)
primarybooleanWhether it is a primary key
autoIncrementbooleanWhether to apply AUTO_INCREMENT
defaultunknownColumn default value (literal or raw SQL in parentheses)
transform(raw) => any(deprecated) Read-only value transform function
transformer{ to, from }Bidirectional value transformer (write and read)
precisionnumberDecimal precision
scalenumberDecimal scale
enumValuesstring[]PostgreSQL ENUM value list
enumNamestringPostgreSQL ENUM type name
renamedFromstringDB column this one was renamed from, so synchronize renames instead of dropping + adding (details)
arrayElementTypeColumnTypeElement type for type: "array" columns (PostgreSQL only, default "text")
bigintMode"number" | "string" | "bigint"Entity-side type of a type: "bigint" column (default "number", which throws beyond ±2^53)

Defining Entities Without Decorators (EntitySchema) ​

Why EntitySchema Exists ​

TypeScript decorators rely on the emitDecoratorMetadata compiler option, which some build tools (esbuild, SWC) do not support. Even with full decorator support, some teams prefer to keep their domain classes free of ORM-specific annotations, following the "plain class" philosophy. EntitySchema addresses both concerns by separating the schema definition from the class itself.

EntitySchema registers the same metadata as the decorator-based approach, so the rest of the ORM (EntityManager, SchemaGenerator, etc.) works identically. Both approaches can coexist in the same project.

Every decorator has a decorator-free form

This section covers the common cases. For the complete decorator → EntitySchema / programmatic map — including computed columns, full-text & JSON indexes, FK columns, bidirectional transformers, UUID strategies, advanced composite indexes, tenant columns, and transactions — see the Decorator-Free Guide.

Basic Usage ​

typescript
import { EntitySchema } from "@stingerloom/orm";

class User {
  id!: number;
  name!: string;
  email!: string;
}

const UserSchema = new EntitySchema<User>({
  target: User,
  tableName: "users",
  columns: {
    id:    { type: "int", primary: true, autoIncrement: true },
    name:  { type: "varchar" },
    email: { type: "varchar", nullable: true, index: true },
  },
});

This produces the exact same DDL as the decorator-based version:

sql
-- PostgreSQL
CREATE TABLE "users" (
  "id" SERIAL PRIMARY KEY,
  "name" VARCHAR(255) NOT NULL,
  "email" VARCHAR(255)
);
CREATE INDEX "INDEX_users_email" ON "users" ("email");

The target class is a plain TypeScript class -- no decorators needed. The tableName is optional; if omitted, it is derived from the class name in snake_case (same as @Entity()).

Column Options ​

ColumnSchemaDef supports the same options as @Column(), plus flags for special decorators:

typescript
columns: {
  id:        { type: "int", primary: true, autoIncrement: true },
  name:      { type: "varchar", length: 100 },
  email:     { type: "varchar", nullable: true, index: true },
  status:    { type: "enum", enumValues: ["active", "inactive"], enumName: "user_status" },
  bio:       { type: "text", nullable: true, default: null },
  version:   { type: "int", version: true },
  createdAt: { type: "datetime", createTimestamp: true },
  updatedAt: { type: "datetime", updateTimestamp: true },
  deletedAt: { type: "datetime", nullable: true, deletedAt: true },
}

The version, createTimestamp, updateTimestamp, and deletedAt flags replace the corresponding @Version(), @CreateTimestamp(), @UpdateTimestamp(), and @DeletedAt() decorators. The generated DDL and runtime behavior are identical.

Relations ​

Relations are defined using the relations option with a kind discriminator:

typescript
class Post {
  id!: number;
  title!: string;
  author!: User;
  comments!: Comment[];
  tags!: Tag[];
}

const PostSchema = new EntitySchema<Post>({
  target: Post,
  columns: {
    id:    { type: "int", primary: true, autoIncrement: true },
    title: { type: "varchar" },
  },
  relations: {
    author: {
      kind: "manyToOne",
      target: () => User,
      joinColumn: "author_id",
      eager: true,
    },
    comments: {
      kind: "oneToMany",
      target: () => Comment,
      mappedBy: "post",
    },
    tags: {
      kind: "manyToMany",
      target: () => Tag,
      joinTable: {
        name: "post_tags",
        joinColumn: "post_id",
        inverseJoinColumn: "tag_id",
      },
    },
  },
});

All four relation kinds are supported:

kindEquivalent DecoratorRequired Options
"manyToOne"@ManyToOne()target, optionally joinColumn, eager, lazy, cascade
"oneToMany"@OneToMany()target, mappedBy
"oneToOne"@OneToOne()target, optionally joinColumn, inverseSide, eager, cascade
"manyToMany"@ManyToMany()target, optionally joinTable (owning) or mappedBy (inverse)

Unique Indexes ​

typescript
const UserSchema = new EntitySchema<User>({
  target: User,
  columns: { /* ... */ },
  uniqueIndexes: [
    { columns: ["email", "tenantId"], name: "uq_user_email_tenant" },
  ],
});

Lifecycle Hooks ​

Point to method names on the target class:

typescript
class Article {
  id!: number;
  title!: string;

  generateSlug() {
    // ...
  }
}

const ArticleSchema = new EntitySchema<Article>({
  target: Article,
  columns: {
    id:    { type: "int", primary: true, autoIncrement: true },
    title: { type: "varchar" },
  },
  hooks: {
    beforeInsert: "generateSlug",
  },
});

Validation ​

Inline validation via the validation array in column definitions:

typescript
columns: {
  name: {
    type: "varchar",
    validation: [
      { constraint: "notNull" },
      { constraint: "minLength", value: 2, message: "Name too short" },
    ],
  },
  age: {
    type: "int",
    validation: [
      { constraint: "min", value: 0 },
      { constraint: "max", value: 150 },
    ],
  },
}

Inheritance Mapping ​

EntitySchema supports all three inheritance strategies (STI, TPT, TPC) using the inheritance, discriminatorColumn, and discriminatorValue options. These replace the @Inheritance(), @DiscriminatorColumn(), and @DiscriminatorValue() decorators.

Single Table Inheritance (STI):

typescript
class Payment {
  id!: number;
  amount!: number;
}

class CreditCardPayment extends Payment {
  cardNumber!: string;
}

class BankTransferPayment extends Payment {
  bankCode!: string;
}

// Root entity: defines strategy + discriminator column
new EntitySchema<Payment>({
  target: Payment,
  inheritance: { strategy: "SINGLE_TABLE" },
  discriminatorColumn: { name: "payment_type", type: "varchar", length: 50 },
  columns: {
    id:     { type: "int", primary: true, autoIncrement: true },
    amount: { type: "int" },
  },
});

// Child entities: only define their own columns + discriminator value
new EntitySchema<CreditCardPayment>({
  target: CreditCardPayment,
  discriminatorValue: "credit_card",
  columns: {
    cardNumber: { type: "varchar", nullable: true },
  },
});

new EntitySchema<BankTransferPayment>({
  target: BankTransferPayment,
  discriminatorValue: "bank_transfer",
  columns: {
    bankCode: { type: "varchar", nullable: true },
  },
});

Child entities automatically inherit parent columns via the prototype chain. For STI, all children share the root's table name. For TPT ("JOINED") and TPC ("TABLE_PER_CLASS"), each child gets its own table.

Joined / Table Per Type (TPT):

typescript
new EntitySchema<Payment>({
  target: Payment,
  inheritance: { strategy: "JOINED" },
  discriminatorColumn: { name: "payment_type" },
  columns: {
    id:     { type: "int", primary: true, autoIncrement: true },
    amount: { type: "int" },
  },
});

new EntitySchema<CreditCardPayment>({
  target: CreditCardPayment,
  discriminatorValue: "credit_card",
  columns: {
    cardNumber: { type: "varchar" },  // NOT NULL is allowed in TPT
  },
});

Table Per Class (TPC):

typescript
new EntitySchema<Payment>({
  target: Payment,
  inheritance: { strategy: "TABLE_PER_CLASS" },
  discriminatorColumn: { name: "payment_type" },
  columns: {
    id:     { type: "int", primary: true, autoIncrement: true },
    amount: { type: "int" },
  },
});

new EntitySchema<CreditCardPayment>({
  target: CreditCardPayment,
  discriminatorValue: "credit_card",
  columns: {
    cardNumber: { type: "varchar" },
  },
});

The discriminatorColumn option is optional on the root entity -- if omitted, a column named "dtype" with type VARCHAR(31) is created by default. The discriminatorValue option is optional on child entities -- if omitted, the class name is used.

All ORM features (EntityManager, QueryBuilder, WriteBuffer, InheritanceResolver) work identically whether entities are defined with decorators or EntitySchema.

TIP

For a detailed comparison of all three strategies with generated SQL examples, see the Inheritance Mapping guide.

Mixing Decorator and EntitySchema Entities ​

Both approaches produce the same metadata, so you can freely mix them:

typescript
// user.entity.ts — uses decorators
@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id!: number;
  // ...
}

// audit-log.schema.ts — uses EntitySchema
class AuditLog {
  id!: number;
  action!: string;
}

const AuditLogSchema = new EntitySchema<AuditLog>({
  target: AuditLog,
  columns: {
    id:     { type: "int", primary: true, autoIncrement: true },
    action: { type: "varchar" },
  },
});

// Both can be used with EntityManager
await em.register({ entities: [User, AuditLog], /* ... */ });

Next Steps ​

Now that you've defined entities, it's time to set up relationships between them.

  • Relations -- Define relationships between tables with @ManyToOne, @OneToMany, etc.
  • EntityManager -- Perform CRUD with your defined entities
  • Transactions -- Group multiple operations into a single unit
  • Migrations -- Safely manage schema changes

Released under the MIT License.