TypeScript Support
Arquebus ORM is a TypeScript rewrite, and as of 3.x it offers real, opt-in type safety. You can describe a model's attributes, relations and table name once and have the model instance, its query builder and its serialized output all become type-aware, while still keeping the loose, dynamic APIs available whenever you want them. Typing is progressive: adopt it where it helps and stay untyped everywhere else.
Basic Usage (Untyped)
The classic form still works exactly as before. Declaring property types with declare gives you editor completion without changing runtime behaviour:
import { Model } from '@h3ravel/arquebus';
// Define a basic model
class User extends Model {
// Optional: declare model property types
declare id: number;
declare name: string;
declare email: string;
}
// Using the model
const user = new User();
user.name = 'John';
await user.save();
// Query example
const users = await User.query().where('age', '>', 18).get();
// Relationship example
class Post extends Model {
declare title: string;
declare content: string;
declare user_id: number;
relationUser() {
return this.belongsTo(User);
}
}Fully Typed Models
For end-to-end type safety, define your model with Model.define<Attributes, Relations, Table>(). The attribute interface flows through the constructor, the accessors (getAttribute, setAttribute, fill, update, toData) and the query builder:
import { Model, arquebus, type Collection } from '@h3ravel/arquebus';
interface PostAttributes {
id: number;
title: string;
published: boolean;
}
class Post extends Model.define<PostAttributes, {}, 'posts'>() {}
interface UserAttributes {
id: number;
email: string;
age: number | null;
active: boolean;
}
interface UserRelations {
posts: Collection<Post>;
}
class User extends Model.define<UserAttributes, UserRelations, 'users'>() {
relationPosts() {
return this.hasMany(Post);
}
}
const user = new User({ id: 1, email: '[email protected]', age: null, active: true });
user.email; // string
user.setAttribute('email', '[email protected]');
user.fill({ age: 37 });
const data = user.toData(); // UserAttributes & Partial<UserRelations>
// Model.query() returns a typed ModelBuilder
await User.query()
.where('email', '[email protected]')
.where('age', '>=', 18)
.whereIn('id', [1, 2, 3])
.with('posts')
.get(); // Promise<Collection<User>>The compiler now rejects mistakes that used to slip through at runtime:
new User({ email: 42 }); // ❌ wrong attribute type
user.setAttribute('age', 'old'); // ❌ wrong value type
User.query().where('missing', 'value'); // ❌ unknown column
User.query().where('active', 'yes'); // ❌ wrong value type
User.query().with('comments'); // ❌ unknown relationTyped Raw Queries
The raw/table query builder can be typed the same way via arquebus.withSchema<Database>(). See the query builder guide for the full example:
interface Database {
users: UserAttributes;
posts: PostAttributes;
}
const db = arquebus.withSchema<Database>();
const rows = await db.table('users').where('active', true).get(); // UserAttributes[]
db.table('posts').insert({ title: 'Typed ORM', published: true });Enhancing Type Safety with Generics
Whether or not you use Model.define(), Arquebus ORM's query methods also accept a generic type argument. This is handy for shaping one-off results (computed columns, raw aggregates or eager-loaded relations) without adding them to the model's attribute interface:
- Extend model type definitions
- Specify relationship data types
- Add custom field types
For example:
// Basic query with generics
const user = await User.query().first<
User & {
computed_field: string;
}
>();
// Relationship query with generics
const post = await Post.query().with('user').first<
Post & {
user: User;
}
>();
// Custom query result type
interface CustomUserResult extends User {
total_posts: number;
latest_login: Date;
}
const result = await User.query()
.select(['*'])
.selectRaw('COUNT(posts.id) as total_posts')
.first<CustomUserResult>();
// Complex relationship query types
const userWithPosts = await User.query().with('posts').first<
CustomUserResult & {
posts: Post[];
}
>();Why This Design?
Our design philosophy is:
- Prioritize Developer Experience: The API stays simple and intuitive; full typing is available through
Model.define()when you want it, never mandatory - Practicality First: In some scenarios you may prefer the looser, dynamic APIs for flexibility, and you can
- Progressive Type Support: You can gradually add more type definitions as needed, from
declarefields to fully typed models and schemas
Best Practices
Despite our approach, we still recommend:
- Declaring types for main model properties
- Adding type annotations to critical business logic code
- Using type assertions or custom type guards where type safety is needed
// Declare types for important model properties
class Product extends Model {
declare id: number;
declare name: string;
declare price: number;
declare stock: number;
// Use explicit types for custom methods
async updateStock(quantity: number): Promise<void> {
this.stock += quantity;
await this.save();
}
}