<!-- Markdown mirror of https://justscale.sh/docs/concepts/type-states -->

# 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 — 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

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

### Persistent — 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

```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> — 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

```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

```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

```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.ts — Refis 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; Ref and Persistentdon't satisfy it.
- 03-di-deps.ts — JustScale().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; the values must be model classes, not strings.
- 05-signal-payloads.ts — defineSignals 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 `X`declares a dep that hasn't been supplied. Here's the same chain with and without the User-repository binding:

TypeScript

```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

```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`). That's enough to know what to add and where.

## Next Steps

- Models as Services
- Locks
- Models Overview

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