Skip to content

Latest commit

 

History

History
308 lines (228 loc) · 10.7 KB

typescript.md

File metadata and controls

308 lines (228 loc) · 10.7 KB

TypeScript

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).

Where attribute types come from

Generated declarations (recommended)

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.

Model.define<T>(table)

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");

What a typed model gets

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 is Collection<Post>, not a relation method.
  • Typed payloads: User.create({ name: 42 }) is a type error when name is 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 | null

Column 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.

Plain extends Model<T>

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()

Query builder typing

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.

Eager-load narrowing

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;     // boolean

Typed accessors

static 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.

Typed scopes

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.

Plain object writes

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 write

Without the marker, ORM keeps its standalone-compatible attribute input types. An empty marked subset rejects every property.

Common pitfalls

  • Accessor parameters widen to any without the annotation. Add : AccessorMap<TAttrs, TModel> to static accessors (or use satisfies).
  • Computed attributes need a declare. Otherwise TS doesn't know user.full_name exists.
  • 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() returns Collection<T>. Call .toArray() when an API requires a plain T[].
  • 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 a relations.ts registry.

Where to next

  • 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.