Type States
How data shape controls what your code can do
In most frameworks, a model instance is a mutable bag of properties. You can change anything, anytime, and hope the ORM figures out what happened. JustScale takes a different approach: the shape of your data tells you what you can do with it.
The Three Forms
Every entity in JustScale exists in one of three forms, each with different capabilities:
Transient<T> — Unsaved, Writable
A newly created entity that hasn't been persisted yet. Nobody else can see it, so it's safe to mutate freely.
const order = new Order({ amount: 100, status: 'pending' });
order.amount = 200; // Fine — it's transient, nobody else has itPersistent<T> — Stored, Readonly
An entity loaded from storage. Other processes might be reading it too, so all fields are readonly. You can read, query, and pass it around — but you cannot mutate it.
const order = await orders.findOne(Order.fields.status.eq('pending'));
order.amount = 200; // Type error! Persistent<Order> fields are readonly
order.amount; // Fine — reading is always safeLock<Persistent<T>> — Stored, Locked, Writable
A persistent entity with an exclusive lock. You've told the framework "I need to mutate this, and nobody else should touch it while I do." The lock removes readonly — mutation is safe again.
using locked = await lockService.acquire(order);
locked.amount = 200; // Fine — you hold the lock
// Lock releases automatically when 'using' scope endsTip
using keyword (TC39 Explicit Resource Management) ensures the lock is always released — even if an error is thrown. No try/finally needed.Methods Declare Their Requirements
The real power comes when model methods declare what form of data they need via TypeScript's this parameter. The type signature IS the contract:
class Order extends defineModel({
fields: {
amount: field.decimal(10, 2),
status: field.enum('Status', ['pending', 'paid', 'shipped']),
},
inject: { payments: PaymentService },
}) {
// Requires a lock — this method mutates
async markPaid(this: Lock<Persistent<Order>>) {
this.status = 'paid';
}
// Works on new orders OR locked ones — both are writable
applyDiscount(this: Transient<Order> | Lock<Persistent<Order>>, pct: number) {
this.amount *= (1 - pct / 100);
}
// Just needs a stored order — read-only access
async loadItems(this: Persistent<Order>) {
return this.payments.getItemsFor(this);
}
// Works on anything — no state requirement
validate() {
return this.amount > 0;
}
}Each method is honest about its needs. The caller knows exactly what to provide:
const order = await orders.findOne(Order.fields.status.eq('pending'));
order.validate(); // Works — no state requirement
order.loadItems(); // Works — order is Persistent
order.markPaid(); // Type error! Need Lock<Persistent<Order>>, got Persistent<Order>
using locked = await lockService.acquire(order);
locked.markPaid(); // Works — locked is Lock<Persistent<Order>>
locked.validate(); // Works — still satisfies "anything"Why This Matters
In traditional frameworks, mutation bugs are runtime surprises:
- Mutating an entity that isn't saved yet — then wondering where the data went
- Mutating without a lock — then hitting a race condition in production
- Calling a method that assumes the entity is persisted — on a transient instance
With type states, these bugs become compile errors. If your code compiles, every method received data in the form it expected. No hidden locking requirements, no surprise side effects.