Skip to content
NewFrom Mongoose to SQL: migrating MongoDB to PostgreSQL7 min read

defineEntity

defineEntity declares an entity as data and returns its class. The row type is derived from the fields, so nothing is written twice, and no decorator is involved. Its options are the decorators’, so the two styles mix freely in one project.

import { f, defineEntity, sql } from 'uql-orm';
import { v7 as uuidv7 } from 'uuid';
import { z } from 'zod';
// Any Standard Schema types the payload: Zod here, or Valibot, ArkType, or one written by hand.
const Settings = z.object({ theme: z.string(), bio: z.string().optional() });
export const User = defineEntity({
name: 'User',
fields: {
id: f.uuid().id().onInsert(uuidv7),
email: f.string().nullable().unique(),
settings: f.json(Settings).nullable(),
},
}).relations((rel) => ({
posts: () => rel.oneToMany(Post, { mappedBy: (post) => post.author }),
}));
// One name for the entity and its rows, as a class has: a function takes `user: User`.
export type User = InstanceType<typeof User>;
export const Post = defineEntity({
name: 'Post',
fields: {
id: f.number().autoIncrement(),
title: f.string(),
status: f.enum(['draft', 'published']).default('draft'),
authorId: f.uuid().nullable(),
publishedAt: f.date().nullable(),
},
})
.computed((post) => ({
slug: f
.string()
.nullable()
.as(sql`lower(${post.title})`),
}))
.indexes((post, index) => [
index([post.title, post.authorId], { unique: true }),
])
.filters({
published: { where: { publishedAt: { $ne: null } }, default: false },
})
.hooks({ beforeInsert: [(post) => void (post.title = post.title.trim())] })
.relations((rel) => ({
author: () => rel.manyToOne(User, { references: (post) => post.authorId }),
}));
export type Post = InstanceType<typeof Post>;

Post reads title and status as always present, status as 'draft' | 'published', and authorId as string | null. An insert may leave status out, since it has a default, and id, which the database assigns.

Both give the same queries and the same types, and you can mix them entity by entity.

Decorators suit you if you think in classes. You get a named type on hover and in errors, and a foreign key right on its column, so entities can point at each other in any order. It is also what TypeORM and MikroORM users already know. They are a little stricter, too:

  • A relation’s mappedBy must name a relation, or a foreign key holding this entity’s key, at compile time. defineEntity takes any member’s name there and checks the rest when the entity is first read.
  • Two entities reading each other never trip TypeScript’s inference, which defineEntity can when an eager read crosses them (TS7022).

defineEntity suits you if you’d rather write each field once. The row type comes from the builders, so entities run 19 to 30% shorter and cost the compiler about 45% less. It needs no decorator transform, which helps when:

  • Your transformer does not implement decorators, like Oxc (Vite 8’s own).
  • You are in NestJS, which keeps experimentalDecorators on for its own DI, and one config cannot compile both decorator specs.
  • You run the CLI on plain node: type stripping runs no decorators, so decorated entities need Bun or tsx.
  • You write JavaScript, or build entities at run time, like a CMS content type.

A column narrower than its builder is a type argument: a branded id, f.uuid<UserId>(), or a JSON payload with no runtime schema, f.json<Settings>(). In return, relations come last as thunks, a foreign key is a relation rather than a column option, and each entity needs a type Post = InstanceType<typeof Post> line. The rest is in what defineEntity asks for.

Both forms share structure. A decorated base class carries its fields, relations, hooks and filters to every entity extending it, and defineEntity does the same with extends. Its fields are a plain object too, so a shared set spreads in: fields: { ...stamped, title: f.string() }.

The object names the entity and its columns:

Key Decorator Notes
name The class name A naming strategy derives the table from it, as from a decorated class’s name.
table @Entity({ name }) The table, exactly as written.
schema @Entity({ schema }) See multiple schemas.
fields @Field Column builders; a key is .id(), as a decorated field declares it.
extends class Child extends Base The class it inherits from, see Inheritance.

Everything else is a section chained after it, each called once, in any order but .relations(), which comes last; only a .filters() reading through the relations may follow it:

Section Decorator Notes
.computed() @Computed Columns the database computes, read off the stored ones: (post) => ({ slug: f.string().as(sql`...`) }).
.indexes() @Entity({ indexes }) (post, index) => [index([post.title], 'by_title')], see Indexes.
.checks() @Entity({ checks }) Check constraints.
.triggers() @Entity({ triggers }) Trigger options.
.filters() @Entity({ filters }) Filter options.
.hooks() Hook decorators Each lifecycle event and its functions of the row: { beforeInsert: [(post, ctx) => ...] }.
.relations() @ManyToOne and the others (rel) => ({ author: () => rel.manyToOne(User, { ... }) }), each taking its relation options.
  • Each relation is a thunk, author: () => rel.manyToOne(...). TypeScript infers two entities that point at each other only when each reads the other lazily, so either can be declared first, in either file.
  • .computed(), .indexes(), .checks(), .triggers() and .hooks() read the columns, not the relations. .filters() reads them too, or, declared after .relations(), the relations at any depth.
  • A JSON column’s payload is a Standard Schema, f.json(schema), typed by its output: any library’s schema (Zod, Valibot, ArkType), or one written by hand. Paths into it are typed in indexes and queries; without one, any path reads.
  • Behaviour on a row is a function of it, label(post: Post): rows read back as plain objects, so a method would never be there.
  • A value over a to-many reads as a column declared in .relations()’ second callback, as a thunk of its aggregate, (author) => ({ bookCount: () => author.books.count(), topStock: () => author.books.max((book) => book.stock) }). The same callback reads the entity’s own columns, so one expression can combine them.
  • Each relation takes the options its cardinality joins by: a manyToOne names its references, a oneToMany its mappedBy, through or references pairs, or nothing, where its target holds one relation pointing back. A to-one’s references column is checked against its target’s key type at compile time; mappedBy is checked when the entity is first read.

When the shape is data, such as a CMS content type an admin creates, the fields are built from it and sync({ entity }) gives the entity a table. See Runtime entities.