ORM is built TypeScript-first. Attribute types reach a model in one of two ways,
declarations generated from the database or an interface passed to
Model.define<T>(), and from there every attribute, relation, and query result is
typed end to end, with no // @ts-ignore needed for ordinary usage.
This document walks through the typing flow: where attribute types come from, how the query builder narrows on every chained call, and where you sometimes need a hand-written annotation (accessors, scopes, custom casts).
Write the model as a plain class and let the database supply the columns:
// src/models/User.ts
import { Model } from "@rekkr/orm";
import { Post } from "./Post";
export class User extends Model {
static override fillable = ["name", "email", "active"];
posts() {
return this.hasMany(Post);
}
}bunx orm types:generate writes src/models/types/users.d.ts, which merges the
table's columns into User. Export the class by name: a declaration merges into a
named export, not into export default class. See
Type Generation.
Without generated declarations, pass an attribute interface and the table name. The returned base class carries the interface:
import { Model } from "@rekkr/orm";
interface UserAttributes {
id: number;
name: string;
email: string | null;
active: boolean;
created_at: Date;
updated_at: Date;
}
export class User extends Model.define<UserAttributes>("users") {
static override fillable = ["name", "email", "active"];
posts() {
return this.hasMany(Post);
}
}The cost is keeping the interface in step with the table by hand.
If the class name doesn't pluralize naturally to your table name (curricula,
media, criteria), pass the singular name as the second argument so
foreign-key inference still works:
class Curriculum extends Model.define<CurriculumAttributes>("curricula") {}
// When assigning to a variable instead of subclassing:
const CurriculumModel = Model.define<CurriculumAttributes>("curricula", "Curriculum");Either way, the class has:
- Property access:
user.name,user.email. Reads through accessors and casts. - Column autocomplete:
User.where("email", ...),User.orderBy("created_at"). - Relation autocomplete:
User.with("posts"),User.with("posts.comments"). - Typed eager-load narrowing: after
.with("posts"), the relation isCollection<Post>, not a relation method. - Typed payloads:
User.create({ name: 42 })is a type error whennameis a string.
// Attribute access
const user = await User.find(1);
user!.name; // string
user!.email; // string | null
user!.active; // boolean
// Column and relation names autocomplete
User.where("email", "a@b.com");
User.orderBy("created_at", "desc");
User.with("posts");
User.with("posts.comments"); // nested
// Typed eager-load results
const users = await User.with("posts").get();
users[0].posts; // Collection<Post>
users[0].json().posts[0].title; // JSON output stays typed too
// Array eager-load lists work too
const admissions = await Admission.with(["student", "program"]).get();
admissions[0].student; // Student | null
// Nested eager-loaded JSON stays typed on child relations
const admissionsWithSubjects = await Admission.with(["subjects", "subjects.subject"]).get();
admissionsWithSubjects[0].json().subjects[0].subject; // Subject | nullColumn and relation names are suggestions, not a closed list: where() also
takes a qualified users.email or a column the model does not declare, and
with() takes any string, so a misspelled name compiles and fails when the query
runs.
With neither generated declarations nor Model.define<T>(), passing the
attribute type as a generic types $attributes and getAttribute(), but adds no
property access or eager-load narrowing:
class User extends Model<UserAttributes> {
static override table = "users";
}
const user = await User.first();
user!.getAttribute("name"); // string
user!.$attributes.email; // string | null
user!.name; // not typed — use getAttribute()Builder<T> is parameterized by the model class, so every result and every column reference narrows correctly:
import type { Collection } from "@rekkr/orm";
const builder = User.where("name", "Alice"); // Builder<User>
const users: Collection<User> = await builder.get();
const usersArray: User[] = users.toArray();
const found: User | null = await User.find(1);
const first: User | null = await User.first();When you use the DB facade without a model class, pass a row-shape generic to get the same column autocomplete:
import { DB } from "@rekkr/orm";
interface UserRow {
id: number;
name: string;
email: string;
active: boolean;
}
const rows = await DB.table<UserRow>("users")
.where("active", true)
.select("id", "name")
.get(); // rows: Collection<UserRow>See Query Builder — Typed columns.
Without .with(), a relation is the relation method itself (() => HasMany<Post>). After .with(), the relation field narrows to its loaded type:
const raw = await User.first();
raw!.posts; // () => HasMany<Post> — relation method
const loaded = await User.with("posts").first();
loaded!.posts; // Collection<Post> — narrowed
const deep = await User.with("posts.comments").first();
deep!.posts[0].comments; // Collection<Comment>Mapping:
| Relation type | Loaded type |
|---|---|
hasMany, belongsToMany, morphMany, morphToMany, hasManyThrough |
Collection<R> |
hasOne, belongsTo, morphOne, morphTo, hasOneThrough |
R | null |
A morphTo loads as Model | null rather than a concrete class, because its
target changes from row to row.
Aggregates like withCount, withSum, and withExists add typed scalar fields:
const users = await User.withCount("posts").withExists("profile").get();
users[0].posts_count; // number
users[0].profile_exists; // booleanstatic accessors callbacks default to (value, attributes, model) => any parameters when the property is unannotated. To get full IntelliSense — including model as the concrete subclass — annotate with AccessorMap<TAttrs, TModel>:
import { Model, type AccessorMap } from "@rekkr/orm";
import type { UsersAttributes } from "./types/users"; // from orm types:generate
export class User extends Model {
declare full_name: string;
static override accessors: AccessorMap<UsersAttributes, User> = {
full_name: {
get: (_value, attributes, model) => {
// value: any
// attributes: UsersAttributes ← typed
// model: User ← typed
return `${attributes.first_name} ${attributes.last_name}`.trim();
},
},
};
}Computed attributes need a declare line on the class so they appear on the instance type. Without it, user.full_name is a type error even though it works at runtime.
The satisfies AccessorMap<UsersAttributes, User> form is supported too:
static override accessors = {
full_name: {
get: (_value, attributes, model) => /* ... */,
},
} satisfies AccessorMap<UsersAttributes, User>;Both styles get the same parameter narrowing. The annotation form gives clearer errors when callback shapes drift.
See Models — Accessors and mutators.
Static scope methods take a Builder<TModel> as their first argument. Annotate with the model class so the scope body has autocomplete:
import type { Builder } from "@rekkr/orm";
class User extends Model {
static scopeActive(query: Builder<User>) {
return query.where("active", true);
}
static scopeRole(query: Builder<User>, role: string) {
return query.where("role", role);
}
}
await User.scope("active").scope("role", "admin").get();scope() takes the name as a plain string, so neither autocomplete nor the compiler checks it. An unknown name throws Scope "nonexistent" is not defined on model User when the query is built.
create, update, and fill accept partial attribute objects, while
forceFill bypasses mass-assignment protection. A typed model provides
autocomplete for known fields, but its standalone-compatible input type still
accepts unknown keys. Declare fillable or guarded for runtime protection:
await User.create({ name: "Alice", email: "a@b.com" }); // ✓
await User.create({ name: "Alice", nonexistent: true }); // discarded by fillable
await User.query().forceCreate({ is_admin: true }); // ✓ trusted input
await user.update({ active: false }); // ✓
user.fill({ name: "Bob" });Code generators, or hand-written declarations, can reject unknown or protected
writes by merging the type-only
ModelMassAssignable<T> marker into the model instance:
interface User extends ModelMassAssignable<Pick<UserAttributes, "name" | "email">> {}
await User.create({ name: "Alice" }); // ✓
await User.create({ is_admin: true }); // ✗
User.where({ is_admin: true }); // ✓ — filters stay broad
user.forceFill({ is_admin: true }); // ✓ — trusted writeWithout the marker, ORM keeps its standalone-compatible attribute input types. An empty marked subset rejects every property.
- Accessor parameters widen to
anywithout the annotation. Add: AccessorMap<TAttrs, TModel>tostatic accessors(or usesatisfies). - Computed attributes need a
declare. Otherwise TS doesn't knowuser.full_nameexists. - Relation type before
.with()is the relation method, not its result. Either eager-load (User.with("posts").get()) or call the relation explicitly (await user.posts().get()). get()returnsCollection<T>. Call.toArray()when an API requires a plainT[].- Circular relation imports. Two models that reference each other will hit a runtime circular import. Use
type-only imports for the relation type and a lazy require inside the relation method, or split via arelations.tsregistry.
- Models — every modeling feature, including soft deletes, scopes, and observers.
- Query Builder — chainable filters, joins, aggregates.
- Type Generation — automatic generation of attribute interfaces from your schema.