Skip to main content

Programmatic Models (defineModel)

Quick Start

Define a model — columns, hooks, constraints, and options. Relations are always defined separately via defineRelations (see Defining Relations).

import { defineModel, col } from "hysteria-orm";

export const Post = defineModel("posts", {
columns: {
id: col.increment(),
title: col.string(),
body: col.text(),
userId: col.integer(),
createdAt: col.datetime({ autoCreate: true }),
updatedAt: col.datetime({ autoCreate: true, autoUpdate: true }),
},
});

export const User = defineModel("users", {
columns: {
id: col.increment(),
name: col.string(),
email: col.string({ nullable: false }),
isActive: col.boolean(),
createdAt: col.datetime({ autoCreate: true }),
updatedAt: col.datetime({ autoCreate: true, autoUpdate: true }),
},
indexes: [["email"]],
uniques: [["email"]],
hooks: {
beforeFetch(qb) {
qb.whereNull("deleted_at");
},
},
});

Use models through a SqlDataSource instance:

const users = await sql.from(User).where("isActive", true).many();
const user = await sql
.from(User)
.insert({ name: "Alice", email: "alice@example.com" });

API Reference

defineModel(table, definition)

ParameterTypeDescription
tablestringThe database table name
definitionModelDefinitionObject describing columns, relations, etc.

Returns a concrete Model subclass.

ModelDefinition

KeyTypeRequiredDescription
columnsRecord<string, ColumnDef>YesColumn definitions using the col namespace
indexesIndexDefinition[]NoTable indexes
uniquesUniqueDefinition[]NoUnique constraints
checksCheckDefinition[]NoCheck constraints
hooksHooksDefinitionNoLifecycle hooks
optionsDefineModelOptionsNoCase conventions, soft delete, etc.
note

Relations are not part of ModelDefinition. They are defined separately with defineRelations.


Column Descriptors (col)

The col namespace provides all column types. Each method returns a ColumnDef that carries the correct TypeScript type for full intellisense.

MethodBase TypeDescription
col<T>()T (user-defined)Generic column — you control the TypeScript type
col.primary<T>()T (default string | number)Generic primary key column
col.increment()numberAuto-incrementing integer primary key (always non-nullable)
col.bigIncrement()numberAuto-incrementing bigint primary key (always non-nullable)
col.integer()numberInteger column
col.bigInteger()number | bigintBig integer column
col.float()numberFloat column
col.decimal()numberDecimal column with optional precision and scale
col.string()stringVARCHAR column with optional length
col.text()stringLONGTEXT column
col.boolean()booleanBoolean column
col.json<T>()T (default unknown)JSON/JSONB column with optional custom type
col.date<T>()T (default Date)DATE column — see Date columns
col.datetime<T>()T (default Date)DATETIME column — see Date columns
col.timestamp<T>()T (default Date)TIMESTAMP column — see Date columns
col.time<T>()T (default Date)TIME column — see Date columns
col.uuid()stringAuto-generates UUID if not provided
col.ulid()stringAuto-generates ULID if not provided
col.binary()Buffer | Uint8Array | stringBinary/blob column
col.enum(values)values[number]Enum constrained to the given values array
col.encryption.symmetric(opts)stringSymmetric encryption column
col.encryption.asymmetric(opts)stringAsymmetric encryption column

Each method accepts the same options as its decorator counterpart (e.g., nullable, hidden, autoCreate, autoUpdate, databaseName, default).

Nullable-Aware Type Inference

The inferred TypeScript type depends on the nullable option:

  • Default (no nullable or nullable: true) — the type includes | null | undefined
  • nullable: false — the type is the base type only, without null or undefined
  • col.increment() / col.bigIncrement() — always number (non-nullable), since auto-increment columns are primary keys
const Product = defineModel("products", {
columns: {
id: col.increment(), // number
name: col.string({ nullable: false }), // string
description: col.string(), // string | null | undefined
price: col.decimal({ precision: 10, scale: 2, nullable: false }), // number
discount: col.decimal({ precision: 10, scale: 2 }), // number | null | undefined
status: col.enum(["draft", "published", "archived"] as const), // "draft" | "published" | "archived" | null | undefined
metadata: col.json(), // unknown
createdAt: col.datetime({ autoCreate: true }), // Date | null | undefined
},
});

This gives you precise types at the model instance level, so TypeScript will flag missing required fields and allow null only where expected.

Date Columns Default Type

col.date(), col.datetime(), col.timestamp(), and col.time() all default to Date because database drivers (PostgreSQL, MySQL, SQLite) return JavaScript Date objects.

If you need string instead, you must:

  1. Pass the generic explicitly: col.datetime<string>()
  2. Provide a serialize function that converts the driver's Date into the string format you want
const Event = defineModel("events", {
columns: {
id: col.increment(),

// Default — driver returns Date objects, type is Date
startedAt: col.datetime({ autoCreate: true }),

// Explicit string — you handle the conversion via serialize
scheduledAt: col.datetime<string>({
serialize: (raw) => new Date(raw).toISOString(),
}),
},
});

type EventInstance = InstanceType<typeof Event>;
// EventInstance.startedAt → Date | null | undefined
// EventInstance.scheduledAt → string | null | undefined
tip

If you only read dates and never need string formatting, the default Date type is the correct choice — no extra configuration needed.

Generic Column Types

Several col methods accept a generic type parameter <T> that lets you override or narrow the default TypeScript type:

  • col<T>() — full control over the type. Use for columns whose type doesn't match any built-in helper.
  • col.primary<T>() — defaults to string | number, override when you know the exact type.
  • col.json<T>() — defaults to unknown. Pass a concrete type for structured JSON data.
  • col.date<T>() / col.datetime<T>() / col.timestamp<T>() / col.time<T>() — default to Date. See Date columns for details on using string instead.
interface UserMetadata {
lastLogin: string;
preferences: Record<string, boolean>;
}

const User = defineModel("users", {
columns: {
id: col.increment(),
externalId: col<string>({ nullable: false }), // string
metadata: col.json<UserMetadata>(), // UserMetadata | null | undefined
createdAt: col.datetime({ autoCreate: true }), // Date | null | undefined
},
});

Typed serialize / prepare

defineModel provides type-safe serialize and prepare callbacks. Unlike the decorator-based API (where both accept and return any), the programmatic API enforces:

  • serialize(value: any) => T — transforms the raw database value and must return the column's TypeScript type.
  • prepare(value: T) => any — receives the column's TypeScript type and returns the value to store.

Not all column types expose these callbacks. Some types handle serialization/preparation internally:

Column typeserializeprepare
col(), col.primary(), col.string(), col.text(), col.binary(), col.enum()YesYes
col.date(), col.datetime(), col.timestamp(), col.time()YesYes
col.integer(), col.bigInteger(), col.float(), col.decimal()Yes
col.increment(), col.bigIncrement()Yes
col.uuid(), col.ulid()Yes
col.boolean(), col.json(), col.encryption.*()
const Product = defineModel("products", {
columns: {
id: col.increment(),
name: col.string({
nullable: false,
// serialize must return string, prepare must accept string
serialize: (raw) => String(raw).trim(),
prepare: (value) => value.toLowerCase(),
}),
price: col.integer({
nullable: false,
// prepare must accept number
prepare: (value) => Math.round(value),
}),
slug: col.uuid({
// serialize must return string | null | undefined
serialize: (raw) => (raw ? String(raw) : null),
}),
token: col<string>({
nullable: false,
// col<string>() → serialize must return string, prepare must accept string
serialize: (raw) => Buffer.from(raw, "base64").toString("utf-8"),
prepare: (value) => Buffer.from(value, "utf-8").toString("base64"),
}),
},
});

TypeScript will report an error if you return the wrong type from serialize or accept the wrong type in prepare:

col.string({
nullable: false,
serialize: (raw) => 42, // Error: Type 'number' is not assignable to type 'string'
prepare: (value: number) => value, // Error: 'number' is not assignable to 'string'
});

Defining Relations (defineRelations + createSchema)

Relations are always defined outside of defineModel, using the defineRelations + createSchema API. This separation is the core design choice that eliminates circular import errors between model files.

How it works

  1. Each model file defines only its columns — no relations, no cross-model imports.
  2. A schema.ts file imports all models and defines relations using defineRelations.
  3. createSchema combines models + relations into an augmented record you use everywhere.

API

defineRelations(model, callback)

ParameterTypeDescription
modelDefinedModelThe model being augmented with relations
callback(helpers: RelationHelpers) => RelationDefsReturns relation definitions using the typed helpers

The callback receives { hasOne, hasMany, belongsTo, manyToMany } helpers. Unlike the rel.* namespace used inside defineModel, these helpers accept direct model references (not lazy callbacks) and type-check foreign keys against the actual target model's columns.

HelperforeignKey checked againstDescription
hasOneTarget model columnsOne-to-one (FK on the target)
hasManyTarget model columnsOne-to-many (FK on the target)
belongsToSource model columnsInverse of hasOne/hasMany (FK here)
manyToManyThrough model columnsMany-to-many via a join model

createSchema(models, relations)

ParameterTypeDescription
modelsRecord<string, DefinedModel>All models keyed by any string identifier
relationsPartial<Record<keyof models, RelationDefs>>Relation definitions keyed by the same string

Returns an augmented record where each value is the model constructor enriched with its full relation types, including nested load() type safety.

Step-by-step example

user.ts — columns only, no cross-model imports:

import { defineModel, col } from "hysteria-orm";

export const User = defineModel("users", {
columns: {
id: col.increment(),
name: col.string({ nullable: false }),
email: col.string({ nullable: false }),
},
hooks: {
beforeFetch(qb) {
qb.whereNull("deleted_at");
},
},
});

post.ts — columns only:

import { defineModel, col } from "hysteria-orm";

export const Post = defineModel("posts", {
columns: {
id: col.increment(),
title: col.string({ nullable: false }),
userId: col.integer({ nullable: false }),
},
});

address.ts and user_address.ts — columns only:

// address.ts
export const Address = defineModel("addresses", {
columns: {
id: col.increment(),
street: col.string(),
city: col.string(),
},
});

// user_address.ts
export const UserAddress = defineModel("user_addresses", {
columns: {
id: col.increment(),
userId: col.integer(),
addressId: col.integer(),
},
});

schema.ts — the single file that imports everything and wires up relations:

import { createSchema, defineRelations } from "hysteria-orm";
import { User } from "./user";
import { Post } from "./post";
import { Address } from "./address";
import { UserAddress } from "./user_address";

const UserRelations = defineRelations(
User,
({ hasOne, hasMany, manyToMany }) => ({
post: hasOne(Post, { foreignKey: "userId" }),
posts: hasMany(Post, { foreignKey: "userId" }),
addresses: manyToMany(Address, {
through: UserAddress,
leftForeignKey: "userId",
rightForeignKey: "addressId",
}),
}),
);

const PostRelations = defineRelations(Post, ({ belongsTo }) => ({
user: belongsTo(User, { foreignKey: "userId" }),
}));

const AddressRelations = defineRelations(Address, ({ manyToMany }) => ({
users: manyToMany(User, {
through: UserAddress,
leftForeignKey: "addressId",
rightForeignKey: "userId",
}),
}));

export const schema = createSchema(
{ users: User, posts: Post, addresses: Address, user_addresses: UserAddress },
{ users: UserRelations, posts: PostRelations, addresses: AddressRelations },
);

// Re-export augmented models — same names, now with full relation types
export const UserModel = schema.users;
export const PostModel = schema.posts;
export const AddressModel = schema.addresses;

Usage — import from schema.ts everywhere, never from the individual model files:

import { UserModel, PostModel } from "./schema";
import { sql } from "./db";

// Full type-safe load() — TypeScript knows the relation names and their types
const users = await sql.from(UserModel).load("posts").many();
const post = await sql.from(PostModel).load("user").one();

// Nested relations also type-check correctly
const usersWithAddresses = await sql
.from(UserModel)
.load("posts", (qb) => qb.load("user"))
.many();

Why this eliminates circular dependencies

  • user.ts and post.ts never import each other — TypeScript is happy.
  • schema.ts is the only file that imports all models. Since schema.ts is never imported by the model files, there is no cycle.
  • Individual model files remain thin and independently testable.

Self-referencing relations

For tree-structured models that reference themselves, use defineRelations with the model itself as the target — no circular import since it's the same file:

import { defineModel, defineRelations, createSchema, col } from "hysteria-orm";

const Category = defineModel("categories", {
columns: {
id: col.increment(),
name: col.string({ nullable: false }),
parentId: col.integer(),
},
});

const CategoryRelations = defineRelations(
Category,
({ belongsTo, hasMany }) => ({
parent: belongsTo(Category, { foreignKey: "parentId" }),
children: hasMany(Category, { foreignKey: "parentId" }),
}),
);

export const schema = createSchema(
{ categories: Category },
{ categories: CategoryRelations },
);
export const CategoryModel = schema.categories;

Cross-model and self-referencing relations can coexist in the same schema file:

// schema.ts
const CommentRelations = defineRelations(Comment, ({ belongsTo, hasMany }) => ({
post: belongsTo(Post, { foreignKey: "postId", nullable: false }),
parent: belongsTo(Comment, { foreignKey: "parentId" }),
replies: hasMany(Comment, { foreignKey: "parentId" }),
}));

Indexes, Uniques & Checks

Constraints are defined as arrays, matching the behavior of @index, @unique, and @check decorators.

Type-safe column references — the indexes and uniques arrays are generic over your column keys. TypeScript will error if you reference a column name that doesn't exist in columns:

defineModel("users", {
columns: {
id: col.increment(),
email: col.string({ nullable: false }),
name: col.string(),
},
indexes: [
["email"], // OK
["name", "email"], // OK
// ["nonExistent"], // Error: Type '"nonExistent"' is not assignable
],
uniques: [["email"]],
});

Indexes

defineModel("users", {
columns: {
/* ... */
},
indexes: [
["email"], // array form
{ columns: ["name", "email"], name: "idx_name_email" }, // object form with custom name
],
});

Uniques

defineModel("users", {
columns: {
/* ... */
},
uniques: [["email"], { columns: ["email"], name: "uq_users_email" }],
});

Checks

defineModel("users", {
columns: {
/* ... */
},
checks: [
"age >= 18", // string form
{ expression: "status IN ('active', 'inactive')", name: "chk_status" }, // object form
],
});

Hooks

Lifecycle hooks let you run logic before or after certain operations. They work identically to static hook methods on decorator-based models, but with typed data — hooks receive the inferred column types instead of any:

HookSignatureWhen it runs
beforeFetch(queryBuilder: ModelQueryBuilder) => void | Promise<void>Before any SELECT query
afterFetch(data: T[]) => T[] | Promise<T[]>After SELECT, can transform results
beforeInsert(data: Partial<T>) => void | Promise<void>Before a single INSERT
beforeInsertMany(data: Partial<T>[]) => void | Promise<void>Before a bulk INSERT
beforeUpdate(queryBuilder: ModelQueryBuilder) => void | Promise<void>Before an UPDATE query
beforeDelete(queryBuilder: ModelQueryBuilder) => void | Promise<void>Before a DELETE query

Where T is the inferred column types from your columns definition. This means data.name is typed as string | null | undefined (not any), and TypeScript will auto-complete column names inside hook callbacks.

const AuditedModel = defineModel("audited_items", {
columns: {
id: col.increment(),
name: col.string(),
createdAt: col.datetime({ autoCreate: true }),
},
hooks: {
beforeInsert(data) {
// data is Partial<{ id: number; name: string | null | undefined; ... }>
data.name = data.name?.trim();
},
afterFetch(rows) {
// rows is { id: number; name: string | null | undefined; ... }[]
return rows.filter((r) => r.name !== "DELETED");
},
},
});

Options

OptionTypeDefaultDescription
modelCaseConventionCaseConvention"camel"Case style for model property names
databaseCaseConventionCaseConvention"snake"Case style for database column names
softDeleteColumnkeyof columns"deletedAt"Column used for soft deletes — must be a defined column name
softDeleteValueboolean | stringCurrent UTC datetimeValue written on soft delete

The softDeleteColumn is type-safe — TypeScript will only accept column names that exist in your columns definition:

const LegacyModel = defineModel("legacy_records", {
columns: {
record_id: col.increment(),
record_name: col.string(),
is_deleted: col.boolean(),
},
options: {
modelCaseConvention: "snake",
databaseCaseConvention: "snake",
softDeleteColumn: "is_deleted", // OK — exists in columns
// softDeleteColumn: "removed", // Error: Type '"removed"' is not assignable
softDeleteValue: true,
},
});

Complete Example

Here is a full User model with columns, constraints, hooks, and relations — using defineModel + defineRelations + createSchema:

user.ts

import { defineModel, col } from "hysteria-orm";

export const User = defineModel("users", {
columns: {
id: col.increment(),
name: col.string(),
email: col.string({ nullable: false }),
password: col({ hidden: true }),
status: col.enum(["active", "inactive"] as const),
isActive: col.boolean(),
balance: col.decimal({ precision: 10, scale: 2 }),
metadata: col.json(),
createdAt: col.datetime({ autoCreate: true }),
updatedAt: col.datetime({ autoCreate: true, autoUpdate: true }),
},
indexes: [["email"]],
uniques: [["email"]],
hooks: {
beforeFetch(qb) {
qb.whereNull("deleted_at");
},
},
});

schema.ts

import { createSchema, defineRelations } from "hysteria-orm";
import { User } from "./user";
import { Post } from "./post";
import { Address } from "./address";
import { UserAddress } from "./user_address";

const UserRelations = defineRelations(
User,
({ hasOne, hasMany, manyToMany }) => ({
post: hasOne(Post, { foreignKey: "userId" }),
posts: hasMany(Post, { foreignKey: "userId" }),
addresses: manyToMany(Address, {
through: UserAddress,
leftForeignKey: "userId",
rightForeignKey: "addressId",
}),
}),
);

export const schema = createSchema(
{ users: User, posts: Post, addresses: Address, user_addresses: UserAddress },
{ users: UserRelations /* PostRelations, AddressRelations ... */ },
);

export const UserModel = schema.users;

TypeScript Types

You can export the inferred instance type for use elsewhere. Generics, nullability, and typed callbacks all flow through:

import { defineModel, col } from "hysteria-orm";

interface ProfileData {
avatar: string;
theme: "light" | "dark";
}

export const User = defineModel("users", {
columns: {
id: col.increment(),
name: col.string({ nullable: false }),
email: col.string({
nullable: false,
// typed, must resolve to string
serialize: (raw) => String(raw).toLowerCase(),
// typed, value must be string
prepare: (value) => value.trim(),
}),
bio: col.string(),
profile: col.json<ProfileData>(),
createdAt: col.datetime({ autoCreate: true }),
// If you need Date | string, widen via generic:
// default: col.datetime<Date | string>({ autoCreate: true }),
// defDate: col.datetime<Date>({ autoCreate: true }),
// defString: col.datetime<string>({ autoCreate: true }),
},
});

type UserInstance = InstanceType<typeof User>;
// UserInstance.id → number
// UserInstance.name → string
// UserInstance.email → string
// UserInstance.bio → string | null | undefined
// UserInstance.profile → ProfileData | null | undefined
// UserInstance.createdAt → Date | null | undefined