Skip to content

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.

What won't compile

Each of the guarantees above is pinned by a fixture under docs/examples/concepts/type-safety/. The // @ts-expect-error directives are run through tsc --noEmit on every build — if one of the expected errors stops firing, the build breaks. So the claims here can't silently drift from what the compiler actually enforces.

  • 01-ref-not-id.tsRef<Order>is not a string; one model's ref cannot be passed where another's is expected.
  • 02-locked-mutations.ts update / save(existing) / delete require Locked<T>; Ref and Persistentdon't satisfy it.
  • 03-di-deps.tsJustScale().add(X) rejects components whose inject:deps aren't registered; the error names the missing token.
  • 04-route-types.ts.types({ Model })transforms path params into Ref<Model>; the values must be model classes, not strings.
  • 05-signal-payloads.tsdefineSignals emit calls check every field: missing fields, wrong types, extras, and Ref-instead-of-Locked all fail to compile.
  • 06-builder-chain.ts — removing a .add(bindRepository(...)) from the builder chain makes the DOWNSTREAM .add(AuthFeature) fail, so the error points at the consumer waiting on the missing dep.

Side-by-side: delete one line, see which .add() breaks

The builder tracks what each step provides and refuses the next .add(X) when Xdeclares a dep that hasn't been supplied. Here's the same chain with and without the User-repository binding:

TypeScript
// ✓ Working — AuthFeature's deps are all satisfied before it's added
const app = JustScale()
  .add(InMemoryLockFeature)
  .add(InMemoryProcessFeature)
  .add(bindRepository(ModelRepository.of(User),    new InMemoryRepository()))
  .add(bindRepository(ModelRepository.of(Session), new InMemoryRepository()))
  .add(bindService(AbstractEmailSender, ConsoleEmailSender))
  .add(AuthFeature)
  .build();
TypeScript
// ✗ Broken — the User repository binding is missing.
// TypeScript flags the .add(AuthFeature) line, NOT the deleted one.
const app = JustScale()
  .add(InMemoryLockFeature)
  .add(InMemoryProcessFeature)
  // .add(bindRepository(ModelRepository.of(User),    new InMemoryRepository()))  ← deleted
  .add(bindRepository(ModelRepository.of(Session), new InMemoryRepository()))
  .add(bindService(AbstractEmailSender, ConsoleEmailSender))
  .add(AuthFeature)
  //   ^^^^^^^^^^^
  //   Argument of type 'FeatureToken<[...]>' is not assignable to
  //   parameter of type 'MissingDepsError<..., ModelRepositoryToken<User, {}>>'.
  .build();

The value isn't "something broke, find it" — the error names the consumer (AuthFeature) and the missing provider (ModelRepositoryToken<User>). That's enough to know what to add and where.

Frequently asked questions

What are the type states in JustScale?

Three: Transient<T> is unsaved and writable, Persistent<T> is stored and read-only, and Locked<T> is stored, locked, and safe to mutate.

Why is a Persistent<T> read-only?

Because other code may be reading it concurrently. To change stored data you must hold a Locked<T>, which you obtain with repo.lock(ref) - an acquire that is atomic with the read.

How do I mutate a stored entity?

Acquire a lock with `using locked = await repo.lock(ref)`, then pass that Locked<T> to repo.update, save, or delete. The lock is your concurrency control, so stale writes are structurally impossible.