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.

TypeScript
const order = new Order({ amount: 100, status: 'pending' });
order.amount = 200;  // Fine — it's transient, nobody else has it

Persistent<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.

TypeScript
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 safe

Lock<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.

TypeScript
using locked = await lockService.acquire(order);
locked.amount = 200;  // Fine — you hold the lock
// Lock releases automatically when 'using' scope ends
💡

Tip

The 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:

TypeScript
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:

TypeScript
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.