Decorators are the class-based way to define a model: annotate properties
instead of building an attributes object. They produce exactly the same model as
prorm.define() — addModel() reads the metadata the decorators recorded and
calls define() with it.The two paths are one-to-one. Anything callable on a model defined with
prorm.define() is callable on one registered with prorm.addModel(), and the
other way round: the same statics, the same instance members, the same
model-scoped mirror of the QueryInterface. That is not
a convention, it is a test — tests/model-api-parity.test.ts compares the two
surfaces member by member, and separately checks that every table-scoped query
interface method has a Model.* equivalent, so a method added to one path fails
the build until it reaches the other. The model is a Proxy, so without that
check a missing member would be undefined at call time rather than a type
error.Beyond the core model decorators there is a much larger set covering
constraints, dialect-specific column types, schema objects, security and query
hints.[!NOTE]
The complete per-decorator reference — every option, every file, and an honest
note on which ones are stubs — is
decorators. This guide covers
how to use them and how they get wired.
The core set#
import { Model, DataTypes, InferAttributes, InferCreationAttributes,
Table, Attribute, PrimaryKey, AutoIncrement, NotNull, Default, Unique } from 'ts-prorm-orm';
@Table({ tableName: 'users', timestamps: true, paranoid: true })
export class User extends Model<InferAttributes<User>, InferCreationAttributes<User>> {
@Attribute(DataTypes.INTEGER())
@PrimaryKey
@AutoIncrement
declare id: number;
@Attribute(DataTypes.STRING(100))
@NotNull
declare name: string;
@Attribute(DataTypes.STRING(255))
@Unique
declare email: string;
@Attribute(DataTypes.DATE())
@Default(DataTypes.NOW)
declare createdAt: Date;
}
const UserModel = prorm.addModel(User);
await prorm.sync();
With a decorated Post holding userId and @HasMany(() => Post, { foreignKey: 'userId', as: 'posts' })
on User, sync() creates both tables — the paranoid deletedAt and the
timestamps come from @Table, the REFERENCES from the association:Query through the model static that addModel() returned — not the class:const UserModel = prorm.addModel(User);
const PostModel = prorm.addModel(Post);
const withPosts = await UserModel.findAll({ include: [{ model: PostModel, as: 'posts' }] });
// SELECT * FROM "users" WHERE "deletedAt" IS NULL;
// SELECT * FROM "posts" WHERE "userId" IN (1);
withPosts[0].posts; // [{ id: 9, title: 'Hi', userId: 1 }]
[!WARNING]
Rows come back as Prorm instances, not as instances of your class:
withPosts[0] instanceof User is false, and a method written in the class
body (greet() { … }) is undefined on them. Keep per-row behaviour in plain
functions (greet(user)), or in a virtual field.
| Decorator | Applies to | Purpose |
|---|
@Table(options?) | class | tableName, schema, timestamps, underscored, paranoid, indexes, scopes, hooks — the same model options. |
@Attribute(type) / @Column(type) | property | Declares the column and its data type. Also takes { type, field, allowNull, defaultValue, primaryKey, autoIncrement, unique, comment }. |
@Field(column) | property | Stores the attribute in a column of another name — see Column names. |
@PrimaryKey | property | Primary key; implies allowNull: false. |
@AutoIncrement | property | Auto-increment / SERIAL / IDENTITY. |
@AllowNull(bool?) / @NotNull | property | Nullability. |
@Default(value) | property | Static value, DataTypes.NOW, or a function. |
@Unique(nameOrTrue?) | property | UNIQUE constraint. |
@Comment(text) | property/class | Column or table comment. |
@Validate(rules) | property/class | Validation rules for a column, or cross-field validators for the model — see Validation. |
Read the metadata back with getModelMetadata, getAttributeMetadata,
getModelAttributes; clearMetadata() resets it, which tests want.Class inheritance works: @AbstractModel marks a base class whose attributes
are inherited (getInheritedAttributeMetadata), so timestamps, paranoid and
shared columns can be declared once.Associations#
Use @HasMany / @HasOne from src/decorators — they record real
metadata:class User extends Model {
@HasMany(() => Post, { foreignKey: 'user_id', onDelete: 'CASCADE' })
posts!: Post[];
@HasOne(() => Profile, { as: 'userProfile' })
userProfile!: Profile;
}
src/
models/
user.ts # @Table class User, @HasMany(() => Post)
post.ts # @Table class Post
index.ts # imports both, then addModel(User), addModel(Post)
The () => Target thunk defers resolution, so two models can refer to each
other regardless of import order. addModel() queues an association whose
target is not registered yet and binds it once it is; sync() flushes whatever
remains.[!IMPORTANT]
@HasOne / @HasMany / @BelongsTo / @BelongsToMany are also exported
from src/models/decorators.ts, where their bodies are empty stubs that exist
only to provide typed signatures. The package root re-exports the functional
versions explicitly, so a root import resolves to the working one. There is no
@BelongsTo / @BelongsToMany implementation under src/decorators/ at all —
declare those with Model.belongsTo(...) / Model.belongsToMany(...). See
Associations.
Decorators that need wiring#
Many feature decorators only record metadata; a companion call installs the
hooks. Forgetting that call is the most common reason a decorator "does
nothing".| Decorator | Wire it with |
|---|
@Secure / @SecureField | nothing — addModel() wires it, and { method: 'scrypt' | 'bcrypt' | 'argon2' } on the field is the whole configuration. See Passwords |
@Encrypt (compliance) | applyEncryptionHooks(Model) |
@Mask (compliance) | applyMaskingHooks(Model) |
@Timezone | applyTimezoneHooks(Model) |
@Default (rich form) | applyDefaultHooks(Model) |
@Procedure / @SqlFunction | prorm.registerProcedures(Class), then sync() |
@View / @MaterializedView / @Trigger | prorm.addModel(Class), then sync() |
@Security | setupSecurity(Model, prorm) or setupAllSecurity(prorm) |
@ExternalField | prorm.registerStore(name, store) |
Wiring a feature decorator takes a key and one call, and the hooks go on after
addModel(). Field encryption, end to end:Mark the field#
@Encrypt is a class decorator that names the field:import { Model, DataTypes, Table, Attribute, PrimaryKey, AutoIncrement, Encrypt } from 'ts-prorm-orm';
@Encrypt('diagnosis')
@Table({ tableName: 'patients', timestamps: false })
export class Patient extends Model {
@Attribute(DataTypes.INTEGER()) @PrimaryKey @AutoIncrement
declare id: number;
@Attribute(DataTypes.TEXT())
declare diagnosis: string;
}
import { FieldEncryption } from 'ts-prorm-orm';
FieldEncryption.configure(process.env.FIELD_KEY!); // 32 bytes, hex
Register the model and install the hooks#
import { applyEncryptionHooks } from 'ts-prorm-orm';
const Patients = prorm.addModel(Patient);
applyEncryptionHooks(Patients);
Check what reaches the database#
await Patients.create({ diagnosis: 'flu' });
(await Patients.findByPk(1)).diagnosis; // 'flu', decrypted on read
Skip the applyEncryptionHooks step and @Encrypt records its metadata and
nothing else: the value is written in plain text, with no error.The wider catalogue#
Grouped by what they do. Full options are in the
decorators.Column behaviour — @Default (values, functions, sequences, SQL literals),
@Comment, @Check / @CheckColumn / @CheckTable, @Generated / @Identity,
@Secure / @SecureField (hash before save; bcrypt/argon2 adapters, library
not bundled), @Timezone, @Collate / @SetCollate.Dialect column types — @UUID, @JSONB, @Range, @HStore (PostgreSQL);
@JSONColumn, @SetColumn, @Spatial (MySQL/MariaDB).Constraints — @References, @OnDelete, @OnUpdate, @Deferrable,
@Unique, @AutoIncrement, @Identity, @GeneratedAlways, @Exclusion.Schema objects — @View, @MaterializedView, @Trigger, @Procedure,
@SqlFunction, @ForeignTable, @DatabaseSetting, @ForeignKeyChecks.Physical storage — @Storage, @Tablespace, @Clustered, @Compression,
@ReplicaIdentity, @Statistics, @OnCommit, @Inherits, @ColumnStorage,
@Partition.Query and session hints — @UseIndex / @ForceIndex / @IgnoreIndex,
@Tablesample, @Distinct, @DistinctOn, @WithLock, @StatementTimeout,
@IdleInTransactionTimeout, @Cursor, @Fetch, @FetchWith.Access & audit — @CanView / @CannotView / @CanWrite / @CannotWrite /
@Permission (evaluated at query time in the ORM, not enforced by the
database), @Audit (in-memory log — see Audit logging).Compliance — @Security, @Encrypt, @Mask, @Classify, @Pseudonymize,
@RateLimit, @LineageTrack. See Compliance.Decorators do not check your dialect#
No decorator inspects the active dialect. Applying @JSONB, @HStore,
@Range, @UUID, @Spatial or @ForeignTable to a model backed by SQLite
will not throw — the metadata is recorded either way, and whether it produces
correct SQL depends entirely on which code path consumes it later. They degrade
silently rather than failing fast.So: keep PostgreSQL-flavoured decorators (@UUID, @JSONB, @HStore,
@Range, @ForeignTable, @Tablespace, @Compression, @ReplicaIdentity,
@Inherits, @Exclusion, @GeneratedAlways, @Tablesample, @DistinctOn) on
PostgreSQL connections, and MySQL/MariaDB-flavoured ones (@JSONColumn,
@SetColumn, @Spatial, @Storage engine options, the index hints,
@DatabaseSetting, the collation constants) on MySQL/MariaDB.There is no single registry.[!CAUTION]
The core model metadata is keyed by the class name (target.name). Two
classes both called User — an admin/User and a billing/User, say — share
one entry, and the second @Table overwrites the first. Give model classes
unique names, and beware of bundlers that minify class names.
src/models/decorators.ts keeps its own
module-level maps; each file under src/decorators/ manages its own storage —
some on the class, some in module maps, a few via reflect-metadata. Each
family therefore exposes its own reader (getHasMany, getAllHasOne,
getTriggers, getViewOptions, getMaskRules, getEncryptedFields, …).ORMDecorators (in orm-decorators.ts) is a plain object listing decorator
names as strings, grouped into MODEL_DECORATORS, CONSTRAINT_DECORATORS,
ASSOCIATION_DECORATORS, HOOK_DECORATORS, VALIDATION_DECORATORS,
POSTGRES_DECORATORS and SETTINGS_DECORATORS. It is for tooling and
reflection; it registers nothing.TypeScript setup#
Decorators need these compiler options:{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "ES2020"
}
}
Declare properties with declare so TypeScript does not emit a class field that
would shadow the model's accessor at runtime. With target ES2022 or later,
TypeScript defaults useDefineForClassFields to true, and a plain id!: number
becomes a real own property: @Attribute(DataTypes.INTEGER())
@PrimaryKey
- id!: number; // emitted as a class field under useDefineForClassFields
+ declare id: number; // type only: nothing is emitted
Modified at 2026-10-04 00:40:16