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.
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.ts—Ref<Order>is not a string; one model's ref cannot be passed where another's is expected.02-locked-mutations.ts—update/save(existing)/deleterequireLocked<T>;RefandPersistentdon't satisfy it.03-di-deps.ts—JustScale().add(X)rejects components whoseinject:deps aren't registered; the error names the missing token.04-route-types.ts—.types({ Model })transforms path params intoRef<Model>; the values must be model classes, not strings.05-signal-payloads.ts—defineSignalsemit calls check every field: missing fields, wrong types, extras, andRef-instead-of-Lockedall 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:
// ✓ 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();// ✗ 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.