Skip to content
NewIn search of the type-safest ORM4 min read

Imperative Definition

defineEntity takes the same options as the decorators and registers identical metadata, checked the same way bar one foreign-key case. Nothing is decorated, so no decorator syntax reaches your build.

Reach for it when:

  • Your transformer implements no decorators, like Oxc (Vite 8’s own).
  • You are in a NestJS app, which must keep experimentalDecorators on for its own DI. That rules out UQL’s standard decorators in the same project.
  • You run the CLI on plain node. Decorators are not erasable syntax, so a uql.config.ts that imports decorated entities needs bun or node --import tsx. A defineEntity entity file is erasable, so type stripping alone loads it.
  • You generate entities at runtime, or want to leave domain classes unannotated.
  • You are writing JavaScript, where a decorator is a SyntaxError unless Bun or a transpiler gets to the file first. A defineEntity call just runs.

Two forms, one registry (@Entity itself calls defineEntity):

Form Use it when
defineEntity(Class, opts) The whole shape is known: the options are checked against the properties the class declares.
defineField(Class, 'name', opts) and friends The shape arrives column by column, or is only known at runtime.
import { v7 as uuidv7 } from 'uuid';
import { defineEntity } from 'uql-orm';
export class User {
id?: string;
name?: string;
email?: string;
}
export class Post {
id?: number;
title?: string;
authorId?: string;
author?: User;
publishedAt?: Date;
}
defineEntity(User, {
fields: {
id: { type: 'uuid', isId: true, onInsert: uuidv7 },
name: { type: String, index: true },
email: { type: String, unique: true, comment: 'User login email' },
},
});
defineEntity(Post, {
fields: {
id: { type: Number, isId: true },
title: { type: String, nullable: false },
authorId: { references: () => User },
publishedAt: { type: Date, nullable: true },
},
relations: {
author: { cardinality: 'm1', entity: () => User },
},
indexes: [{ columns: (post) => [post.title, post.authorId], unique: true }],
filters: {
published: { condition: { publishedAt: { $ne: null } }, default: false },
},
});

Every entry of either form has a decorator equivalent:

Key Decorator equivalent Notes
name @Entity({ name }) Custom table name. Defaults to the class name, so name it explicitly if your build minifies.
fields @Field / @Id Same field options; mark the primary key with isId: true instead of @Id.
relations @OneToOne, @OneToMany, @ManyToOne, @ManyToMany Same relation options, plus cardinality: '11', '1m', 'm1', or 'mm'.
indexes @Index { columns: (post) => [post.title], name?, unique?, type?, where? }, see Indexes.
hooks Hook decorators Maps each lifecycle event to the methods it runs: { beforeInsert: (post) => [post.stamp] }.
filters @Filter Same filter options: { condition, default?, security?, onMissing? }.
extends class Child extends Base The base to inherit fields, relations, hooks and filters from. See Inheritance.

Two things the decorators check go unchecked here. A foreign key declared with references and no type resolves its column type from the referenced primary key under both APIs, but only @Field also checks the property’s own type against that key, so authorId?: number pointing at a uuid key compiles here and not there. And where @Id refuses a key the type level cannot name, isId: true does not: brand an unconventional or composite key yourself, or every by-id method is typed against the wrong column.

For dynamic schemas, register piece by piece with defineField, defineId, defineRelation, defineIndex, defineFilter, and defineHook, then call defineEntity last. It validates the metadata (fields present, exactly one primary key) and finalizes the entity:

import { v7 as uuidv7 } from 'uuid';
import {
defineEntity,
defineField,
defineFilter,
defineHook,
defineId,
defineIndex,
defineRelation,
type HookContext,
} from 'uql-orm';
class Article {
id?: string;
title?: string;
authorId?: string;
author?: User;
createdAt?: Date;
publishedAt?: Date;
stamp(_ctx: HookContext): void {
this.createdAt = new Date();
}
}
defineId(Article, 'id', { type: 'uuid', onInsert: uuidv7 });
defineField(Article, 'title', { type: String, nullable: false });
defineField(Article, 'createdAt', { type: Date });
defineField(Article, 'publishedAt', { type: Date, nullable: true });
defineRelation(Article, 'author', { cardinality: 'm1', entity: () => User });
defineIndex(Article, { columns: (article) => [article.title], unique: true });
defineFilter(Article, 'published', {
condition: { publishedAt: { $ne: null } },
default: false,
});
defineHook(Article, 'stamp', 'beforeInsert');
defineEntity(Article, { name: 'articles' });

Both APIs write to the same metadata registry, so you can mix styles within one project, and everything downstream (querying, migrations, the HTTP transport) behaves identically.

When the shape is data rather than source (a CMS content type an admin creates, a tenant whose columns are rows in a table), the same call registers it and sync({ entity }) gives it a table. See Runtime Schemas.