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