Models
Define your domain models with type-safe field builders
Models in JustScale define your domain entities using a declarative field builder API. They provide compile-time type safety, automatic TypeScript type inference, and separation between your domain schema and storage implementation.
Creating a Model
Use defineModel to create a model class. The model extends the returned class, and TypeScript automatically infers the types from your field definitions:
import { defineModel, field } from '@justscale/core/models';
// Define a User model with field builders
class User extends defineModel({
email: field.string().max(255).unique(),
name: field.string().max(100),
balance: field.decimal(10, 2).default('0.00'),
isActive: field.boolean().default(true),
}) {}
// TypeScript infers the type automatically:
// {
// email: string
// name: string
// balance: string // Decimals are strings for precision
// isActive: boolean
// }
export { User };Creating Instances
Models can be instantiated directly with new. The constructor accepts partial data for all fields. This creates a transient instance (not yet persisted):
import { User } from './user.model';
// Create a new transient user (no id yet)
const user = new User({
email: 'alice@example.com',
name: 'Alice',
// balance defaults to '0.00'
// isActive defaults to true
});
console.log(user.email); // 'alice@example.com'
console.log(user.balance); // '0.00'Field Builders
Field builders provide a fluent API for defining field types and constraints. Each field type has specific modifiers and options:
import { defineModel, field } from '@justscale/core/models';
class Product extends defineModel({
// String fields
sku: field.string().max(50).unique().index(),
name: field.string().max(200),
description: field.text(), // For long content
// Number fields
quantity: field.int().default(0),
price: field.decimal(10, 2), // Precision for money
weight: field.float().optional(),
// Boolean
inStock: field.boolean().default(true),
// Date/Time
manufacturedAt: field.timestamp().optional(),
// JSON
metadata: field.jsonb<{ tags: string[] }>().optional(),
}) {}
export { Product };See Field Builders Reference for a complete list of available field types and modifiers.
Persistence and Type States
When you persist a model via a repository, you get back a Persistent<T> — a readonly version of your domain data. The adapter tracks identity, timestamps, and versioning internally via non-enumerable symbols. Your domain code never sees .id or system fields:
import { defineService } from '@justscale/core';
import { ModelRepository, type Persistent } from '@justscale/core/models';
import { User } from './user.model';
export class UserService extends defineService({
inject: { users: ModelRepository.of(User) },
factory: ({ users }) => ({
create: async (email: string, name: string): Promise<Persistent<User>> => {
const saved = await users.insert({ email, name });
saved.email; // string (readonly)
saved.name; // string (readonly)
// saved.id — does not exist! ID is an adapter concern
return saved;
},
findByEmail: async (email: string) => {
return users.findOne(User.fields.email.eq(email));
},
}),
}) {}Tip
Persistent<T> has the same fields as your model, but all readonly. To mutate a persistent entity, acquire a lock first — see Type States.If you need a raw identifier at a system boundary (URLs, external APIs), use the deliberately verbose escape hatch:
const rawId = User.ref(savedUser).identifier;For referencing entities in domain code, use typed references instead — see References.
Permissions
Models can declare who is allowed to do what directly in their definition. The permissions block receives the model's field expressions and returns a record of named checks built with permit(). Each check is stamped onto the model as Model.can.name and is also available as a route guard via the @justscale/permission middleware.
import { defineModel, field } from '@justscale/core/models';
import { permit, Everyone } from '@justscale/permission';
import { Customer } from './customer.model';
import { Agent } from './agent.model';
export class Ticket extends defineModel({
fields: {
subject: field.string().max(255),
body: field.text(),
status: field.enum('TicketStatus',
['open', 'in_progress', 'resolved', 'closed']).default('open'),
customer: field.ref(Customer),
assignedAgent: field.ref(Agent).optional(),
escalated: field.boolean().default(false),
internalNotes: field.text().optional(),
},
// 'customer' is the field expression — permit(Customer).when(customer)
// means: the principal is a Customer whose ref equals this ticket.customer.
permissions: ({ customer }) => ({
// Array = any of these passes
view: [permit(Customer).when(customer), permit(Agent).always()],
comment: [permit(Customer).when(customer), permit(Agent).always()],
// Single rule
assign: permit(Agent).always(),
resolve: permit(Agent).always(),
close: permit(Customer).when(customer),
// Everyone is a principal that matches any caller — use for public rules
viewPublicSummary: permit(Everyone).always(),
}),
}) {}Using permission names
The keys of the permissions record are stamped on Model.can:
// As an HTTP route guard (@justscale/permission)
Get('/tickets/:ticket')
.types({ Ticket })
.guard(Ticket.can.view)
.handle(({ params, res }) => {
// Middleware proved the principal passes one of the 'view' rules.
});
// Direct check with a resolved principal
if (await Ticket.can.assign({ principal, subject: ticket })) {
await ticketService.assign(ticket, agent);
}Queryable rules (ORM-style filters)
Rules built with .when(field) are queryable: the same permission serves as both a row guard AND a WHERE-clause generator via .toCondition(principal). This is how you filter list endpoints without writing the predicate twice.
// Declared once on the model:
// close: permit(Customer).when(customer)
//
// At runtime, .toCondition(principal) evaluates to an EqCondition on the
// same field — something the repository translates into SQL directly:
const principal = await principals.current();
const condition = Ticket.can.view.toCondition(principal);
// → EqCondition { field: 'customer', value: principal.ref }
// → SQL: WHERE tickets.customer_id = :principal
// Pass it straight to the repository — no manual predicate needed.
const visible = await tickets.find({ where: condition });
// Combine with other filters via q.and / q.or:
const openForMe = await tickets.find({
where: q.and(condition, Ticket.fields.status.eq('open')),
});Traversing refs (JOIN-style)
When the owning relation isn't direct, .when(field.has(Other.fields.X))walks the ref chain. It produces a nested HasCondition that the adapter compiles to a JOIN:
// Attachment doesn't reference Customer directly — it references a Ticket,
// which references a Customer. The traversal expresses the JOIN path.
export class Attachment extends defineModel({
fields: {
ticket: field.ref(Ticket),
filename: field.string(),
},
permissions: ({ ticket }) => ({
// permit(Customer).when(ticket.has(Ticket.fields.customer))
// guard: resolve attachment.ticket → check ticket.customer === principal
// query: HasCondition {
// field: 'ticket',
// condition: EqCondition { field: 'customer', value: principal.ref }
// }
// SQL: JOIN tickets ON tickets.id = attachments.ticket_id
// WHERE tickets.customer_id = :principal
view: [permit(Customer).when(ticket.has(Ticket.fields.customer)),
permit(Agent).always()],
upload: [permit(Customer).when(ticket.has(Ticket.fields.customer)),
permit(Agent).always()],
}),
}) {}Tip
.when(field) whenever the rule can be expressed as a field comparison — you get ORM integration for free. Reach for .check(fn) only for logic that cannot be projected to a queryable condition (e.g. time-of-day checks, external policy lookups). Non-queryable rules work as guards but cannot filter list endpoints.Field-level access
The optional access block attaches permissions to individual fields. Fields default to "visible and mutable by anyone allowed to see/edit the record," but access tightens specific fields to specific permissions:
export class Ticket extends defineModel({
fields: {
subject: field.string(),
body: field.text(),
internalNotes: field.text().optional(),
assignedAgent: field.ref(Agent).optional(),
escalated: field.boolean().default(false),
},
permissions: ({ customer }) => ({
view: [permit(Customer).when(customer), permit(Agent).always()],
assign: permit(Agent).always(),
}),
// access receives the permissions map — reuse named rules
access: ({ assign }) => ({
// Only callers who pass 'assign' can see or set these
internalNotes: assign,
assignedAgent: assign,
escalated: assign,
}),
}) {}
// A Customer fetching a ticket gets back only subject/body — the
// assign-gated fields are filtered out at serialization time.Fields with no access entry follow the model-wide default (typically view); fields listed are scoped to the referenced permission. The same rule governs visibility (read) and mutability (write).
Tip
permit(Everyone) is for rules that should pass for any caller (including anonymous). Declare Everyone-based rules last when you want owner-specific rules (e.g. viewAsOwner) to be checked first — earlier rules in an array win.Type Inference
JustScale models use TypeScript's advanced type inference to extract types directly from your field definitions. You rarely need to write explicit types:
import { defineModel, field, type ModelData } from '@justscale/core/models';
class User extends defineModel({
email: field.string(),
age: field.int().optional(),
balance: field.decimal(10, 2),
active: field.boolean().default(true),
}) {}
// Extract clean type reference
type UserData = ModelData<typeof User>;
// {
// email: string
// age: number | undefined
// balance: string
// active: boolean
// }
// TypeScript infers parameter types
function greetUser(user: UserData) {
console.log(`Hello, ${user.email}`);
}
const user = new User({ email: 'test@example.com', balance: '100.00' });
greetUser(user); // Type-safe!Special Field Types
Timestamps
Use semantic timestamp fields for common patterns like created/updated tracking:
import { defineModel, field } from '@justscale/core/models';
class Post extends defineModel({
title: field.string(),
content: field.text(),
publishedAt: field.timestamp().optional(),
createdAt: field.createdAt(), // Auto-set on insert
updatedAt: field.updatedAt(), // Auto-updated on save
deletedAt: field.deletedAt(), // Soft delete support (optional by default)
}) {}
export { Post };Enums
Define PostgreSQL enums with type-safe values:
import { defineModel, field } from '@justscale/core/models';
const ORDER_STATUSES = ['pending', 'processing', 'completed', 'cancelled'] as const;
class Order extends defineModel({
orderNumber: field.string().max(50).unique(),
status: field.enum('order_status', ORDER_STATUSES).default('pending'),
total: field.decimal(10, 2),
}) {}
// TypeScript knows status must be one of the enum values
const order = new Order({
orderNumber: 'ORD-001',
status: 'pending', // Type-safe
// status: 'invalid', // TypeScript error
total: '99.99',
});
export { Order };Arrays
Store arrays of primitive types using PostgreSQL array columns:
import { defineModel, field } from '@justscale/core/models';
class Article extends defineModel({
title: field.string(),
tags: field.array(field.string()),
ratings: field.array(field.int()).optional(),
}) {}
const article = new Article({
title: 'TypeScript Tips',
tags: ['typescript', 'programming', 'web'],
ratings: [4, 5, 5, 3],
});
export { Article };Best Practices
- Use semantic field types - Prefer
field.createdAt()overfield.timestamp()for timestamps - Set appropriate constraints - Use
.max(),.unique(),.index()to express domain rules - Use decimal for money - Never use
floatordoublefor currency; usefield.decimal(10, 2) - Provide defaults - Set sensible defaults with
.default()to reduce required constructor parameters - Let TypeScript infer - Avoid manual type annotations; let the field builders do the work
- Export the class, not the instance - Export
Userclass, notnew User()