Models
Models are ActiveRecord-style classes that map to a database table. Extend Model, configure mass-assignment and serialization, then use static and instance methods for persistence.
Defining a model
import { Model } from 'mevn-orm'
class User extends Model {
/** Column types for the language server and typechecker (see below). */
declare name: string
declare email: string
declare password: string
/** Columns allowed when calling instance `save()`. */
override fillable = ['name', 'email', 'password']
/** Columns excluded from `toArray()` and stripped after reads. */
override hidden = ['password']
}Configuration properties
| Property | Description |
|---|---|
fillable | Attributes allowed through instance save() mass assignment |
hidden | Attributes excluded from toArray() and stripped after reads |
table | Override the inferred database table name |
id | Primary key (set after insert / find) |
modelName | Snake_case singular name from the class name (used for default foreign keys) |
Typing attributes (LSP / TypeScript)
Static helpers such as User.find() already return your derived class (User | null), not bare Model. The language server still needs to know which columns exist on that class.
Database values are assigned at runtime (Object.assign / row load). They are not constructor field initializers, so declare columns with TypeScript declare fields — type-only, no emitted runtime code:
class User extends Model {
declare id?: number
declare name: string
declare email: string
declare password: string
declare role?: string
override fillable = ['name', 'email', 'password']
override hidden = ['password']
}
const user = await User.findOrFail(1)
user.name // string
user.email.length // number
// user.nme // still allowed via Model's index signature — declare columns you care aboutWhy declare? Prefer declare name: string over name!: string for ORM models. Attributes come from the database; declare documents that without emitting class-field initialization.
Alternative: interface merging
You can put column types on a merged interface instead of declare fields:
interface User {
name: string
email: string
password: string
}
class User extends Model {
override fillable = ['name', 'email', 'password']
override hidden = ['password']
}Instance members from the interface merge with the class, so user.name is still typed as string.
What this does not type yet
Declaring columns improves reads on instances (user.name). Payloads for create, where, and update remain loosely typed (Record-style) today. Until the library adds attribute generics, you can narrow writes in app code:
type UserCreate = Pick<User, 'name' | 'email' | 'password'>
await User.create({
name: 'Jane',
email: 'jane@example.com',
password: hashedPassword,
} satisfies UserCreate)Notes
hiddenand types: TypeScript does not droppasswordfrom the type after load just because it is inhidden. RuntimetoArray()/ stripping still apply; usetoArray()for API responses when you need secrets omitted.- Optional columns: Use
declare role?: stringfor nullable or not-always-selected fields. - Primary key:
id?: numberis already onModel; re-declaring it on the subclass is optional.
Table name inference
class User extends Model {}
// table → "users"
class PasswordResetToken extends Model {}
// table → "password_reset_tokens"
class PasswordReset extends Model {
override table = 'password_reset_tokens' // force a specific table
}Helpers used under the hood:
import { getTableName, toSnakeCase } from 'mevn-orm'
getTableName('PasswordResetToken') // 'password_reset_tokens'
toSnakeCase('PasswordResetToken') // 'password_reset_token'Creating records
Model.create
const user = await User.create({
name: 'Jane Doe',
email: 'jane@example.com',
password: hashedPassword
})
console.log(user.id) // 1
console.log(user.name) // 'Jane Doe'
// password is stripped because it is in `hidden`Model.createMany
const users = await User.createMany([
{ name: 'Ada', email: 'ada@example.com', password: hash1 },
{ name: 'Grace', email: 'grace@example.com', password: hash2 }
])Instance save()
save() only persists fields listed in fillable:
const user = new User()
user.name = 'Linus'
user.email = 'linus@example.com'
user.password = hashedPassword
// user.role = 'admin' // ignored by save() unless listed in fillable
await user.save()
// user.id is now set; row reloaded from the databasefirstOrCreate
Find by attributes, or create with merged values:
const user = await User.firstOrCreate(
{ email: 'jane@example.com' },
{ name: 'Jane Doe', password: hashedPassword }
)
// Second call with the same email returns the existing row
const same = await User.firstOrCreate({ email: 'jane@example.com' })Reading records
// By primary key
const user = await User.find(1) // User | null
const user2 = await User.findOrFail(1) // User, or throws
// Select specific columns
const partial = await User.find(1, ['id', 'email'])
// First / all (see Queries guide for scopes)
const first = await User.first()
const all = await User.all()findOrFail throws a clear error when missing:
try {
await User.findOrFail(999)
} catch (error) {
// Error: User with id "999" not found
}Static methods preserve the derived class type in TypeScript:
const u: User | null = await User.find(1)
// not Model | nullWith typed attributes on User, u.name is also typed as string (not only u as User).
Updating records
Instance update
const user = await User.findOrFail(1)
const updated = await user.update({ name: 'Jane Updated' })
console.log(updated.name) // 'Jane Updated'Requires id on the instance. Throws if missing.
Bulk update
// All rows (use carefully)
await User.update({ archived: true })
// Scoped
await User.where({ active: false }).update({ archived: true })Deleting records
Instance delete
const user = await User.findOrFail(1)
await user.delete()Bulk destroy
await User.where({ archived: true }).destroy()Complete example
import { configureDatabase, Model } from 'mevn-orm'
configureDatabase({
client: 'better-sqlite3',
connection: { filename: './dev.sqlite' }
})
class Post extends Model {
override fillable = ['user_id', 'title', 'body', 'published']
override hidden = []
}
async function blogDemo() {
// Create
const post = await Post.create({
user_id: 1,
title: 'Hello Mevn',
body: 'First post',
published: true
})
// Read
const found = await Post.findOrFail(post.id as number)
// Update
await found.update({ title: 'Hello Mevn ORM' })
// Query
const published = await Post
.where({ published: true })
.orderBy('id', 'desc')
.limit(10)
.all()
console.log(published.toArray())
// Delete
await found.delete()
}Next steps
- Queries — scopes, pagination, counting
- Relationships
- Serialization