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)
| Parameter | Type | Description |
|---|---|---|
table | string | The database table name |
definition | ModelDefinition | Object describing columns, relations, etc. |
Returns a concrete Model subclass.
ModelDefinition
| Key | Type | Required | Description |
|---|---|---|---|
columns | Record<string, ColumnDef> | Yes | Column definitions using the col namespace |
indexes | IndexDefinition[] | No | Table indexes |
uniques | UniqueDefinition[] | No | Unique constraints |
checks | CheckDefinition[] | No | Check constraints |
hooks | HooksDefinition | No | Lifecycle hooks |
options | DefineModelOptions | No | Case conventions, soft delete, etc. |
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.
| Method | Base Type | Description |
|---|---|---|
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() | number | Auto-incrementing integer primary key (always non-nullable) |
col.bigIncrement() | number | Auto-incrementing bigint primary key (always non-nullable) |
col.integer() | number | Integer column |
col.bigInteger() | number | bigint | Big integer column |
col.float() | number | Float column |
col.decimal() | number | Decimal column with optional precision and scale |
col.string() | string | VARCHAR column with optional length |
col.text() | string | LONGTEXT column |
col.boolean() | boolean | Boolean 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() | string | Auto-generates UUID if not provided |
col.ulid() | string | Auto-generates ULID if not provided |
col.binary() | Buffer | Uint8Array | string | Binary/blob column |
col.enum(values) | values[number] | Enum constrained to the given values array |
col.encryption.symmetric(opts) | string | Symmetric encryption column |
col.encryption.asymmetric(opts) | string | Asymmetric 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
nullableornullable: true) — the type includes| null | undefined nullable: false— the type is the base type only, withoutnullorundefinedcol.increment()/col.bigIncrement()— alwaysnumber(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:
- Pass the generic explicitly:
col.datetime<string>() - Provide a
serializefunction that converts the driver'sDateinto 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
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 tostring | number, override when you know the exact type.col.json<T>()— defaults tounknown. Pass a concrete type for structured JSON data.col.date<T>()/col.datetime<T>()/col.timestamp<T>()/col.time<T>()— default toDate. See Date columns for details on usingstringinstead.
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 type | serialize | prepare |
|---|---|---|
col(), col.primary(), col.string(), col.text(), col.binary(), col.enum() | Yes | Yes |
col.date(), col.datetime(), col.timestamp(), col.time() | Yes | Yes |
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
- Each model file defines only its columns — no relations, no cross-model imports.
- A
schema.tsfile imports all models and defines relations usingdefineRelations. createSchemacombines models + relations into an augmented record you use everywhere.
API
defineRelations(model, callback)
| Parameter | Type | Description |
|---|---|---|
model | DefinedModel | The model being augmented with relations |
callback | (helpers: RelationHelpers) => RelationDefs | Returns 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.
| Helper | foreignKey checked against | Description |
|---|---|---|
hasOne | Target model columns | One-to-one (FK on the target) |
hasMany | Target model columns | One-to-many (FK on the target) |
belongsTo | Source model columns | Inverse of hasOne/hasMany (FK here) |
manyToMany | Through model columns | Many-to-many via a join model |
createSchema(models, relations)
| Parameter | Type | Description |
|---|---|---|
models | Record<string, DefinedModel> | All models keyed by any string identifier |
relations | Partial<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.tsandpost.tsnever import each other — TypeScript is happy.schema.tsis the only file that imports all models. Sinceschema.tsis 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:
| Hook | Signature | When 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
| Option | Type | Default | Description |
|---|---|---|---|
modelCaseConvention | CaseConvention | "camel" | Case style for model property names |
databaseCaseConvention | CaseConvention | "snake" | Case style for database column names |
softDeleteColumn | keyof columns | "deletedAt" | Column used for soft deletes — must be a defined column name |
softDeleteValue | boolean | string | Current UTC datetime | Value 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