Skip to content

Entities

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.

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.

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 */ }

@PrimaryGeneratedColumn() defines an auto-increment (AUTO_INCREMENT) primary key. The DB automatically fills in 1, 2, 3... in sequence even if you don't provide a value during INSERT.

@Column() defines a regular column. It reads the TypeScript type and automatically infers the appropriate DB type — string becomes VARCHAR(255), number becomes INT.

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
}

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.

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;

Allowing NULL

By default, all columns are NOT NULL. For columns that may not have a value, set nullable: true.

typescript
@Column({ nullable: true })
bio!: string | null;

Adding | null to the TypeScript type as well allows natural null checking in your code.

Column Name Alias

Use the name option when you want the property name and the actual DB column name to differ.

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

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;

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.

JSON Column

Use the JSON type to store structured data in a single column.

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

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;

PostgreSQL ENUM

You can use custom ENUM types in PostgreSQL.

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

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

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.

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;
}

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

Improving Query Performance with Indexes

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() automatically creates an index in the format INDEX_user_email. Be sure to add it if you frequently use email in WHERE conditions.

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.

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 creates CREATE INDEX idx_order_tenantId_status ON order (tenantId, status). 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 creates a UNIQUE INDEX on the (categoryId, slug) combination. 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.

Optimistic Locking (@Version)

Conflicts can occur when multiple users modify the same data simultaneously. Using the @Version() decorator, WHERE version = currentVersion is automatically added during UPDATE, and simultaneously version = currentVersion + 1 is set. If another user modified the data first, the version will differ and the UPDATE will fail.

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;
}

Hint Optimistic locking is suitable when conflicts are rare but data integrity is important (e.g., order status changes, inventory management).

Soft Delete (@DeletedAt)

Sometimes you want to mark data as "deleted" without actually removing it, like putting a post in the trash.

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;
}

Adding the @DeletedAt() decorator changes three things.

First, calling em.softDelete(Post, { id: 1 }) records the current time in deleted_at instead of deleting the row.

Second, em.find(Post) automatically adds WHERE deleted_at IS NULL to exclude deleted data.

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

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

Automatic Timestamps (@CreateTimestamp / @UpdateTimestamp)

Most entities need createdAt and updatedAt columns. Instead of manually setting these with lifecycle hooks, use the built-in timestamp decorators.

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;
}

@CreateTimestamp() automatically sets the current time on INSERT. The value is never changed on subsequent UPDATEs.

@UpdateTimestamp() automatically sets the current time on both INSERT and UPDATE.

Both decorators create a DATETIME (MySQL) / TIMESTAMP (PostgreSQL) NOT NULL column. If you need timezone-aware timestamps in PostgreSQL, use @Column({ type: "timestamptz" }) with lifecycle hooks instead.

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.

Lifecycle Hooks

You can define code that runs automatically when an entity is saved, updated, or deleted. This is useful for custom logic beyond what @CreateTimestamp/@UpdateTimestamp provide.

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 INSERT, and @BeforeUpdate() just before UPDATE.

There are 6 available lifecycle hooks in total.

DecoratorExecution TimingWhen to Use
@BeforeInsert()Just before INSERTDefault values, timestamps
@AfterInsert()After INSERT completesLogging, sending notifications
@BeforeUpdate()Just before UPDATEUpdating updatedAt
@AfterUpdate()After UPDATE completesRecording change history
@BeforeDelete()Just before DELETECleanup before deletion
@AfterDelete()After DELETE completesCleaning up related resources, logging

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

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.

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

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`);
  }
}

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
Othertext0true

DB Mapping by ColumnType

ColumnTypeMySQL/MariaDBPostgreSQLSQLite
varcharVARCHAR(n)VARCHAR(n)TEXT
int / numberINTINTEGERINTEGER
floatFLOATREALREAL
doubleDOUBLEDOUBLE PRECISIONREAL
bigintBIGINTBIGINTINTEGER
booleanTINYINT(1)BOOLEANINTEGER
datetimeDATETIMETIMESTAMPTEXT
timestampTIMESTAMPTIMESTAMPTEXT
timestamptzDATETIMETIMESTAMPTZTEXT
dateDATEDATETEXT
textTEXTTEXTTEXT
longtextLONGTEXTTEXTTEXT
blobBLOBBYTEABLOB
jsonJSONJSONTEXT
jsonbJSONJSONBTEXT
enumENUM(custom ENUM)TEXT

@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) => anyValue transform function when reading from DB
precisionnumberDecimal precision
scalenumberDecimal scale
enumValuesstring[]PostgreSQL ENUM value list
enumNamestringPostgreSQL ENUM type name

Defining Entities Without Decorators (EntitySchema)

If you prefer not to use decorators — for example, when using build tools like esbuild or SWC that don't support emitDecoratorMetadata, or simply to keep schema definitions separate from your classes — you can use EntitySchema.

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.

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 },
  },
});

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.

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 },
    ],
  },
}

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.