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

# References

ID-free domain modeling with type-safe references

In every other framework, entities reference each other via string or numeric IDs. These IDs are infrastructure details that leak into your domain code, cause type confusion, and create implicit coupling to your storage layer.

JustScale replaces IDs with **type-safe references** that the compiler can verify.

## The Problem with IDs

TypeScript

```typescript
// The problem: string IDs everywhere
const user = await userRepo.findById('abc123');
const order = await orderRepo.findById(user.orderId);

user.id;        // string
order.userId;   // string
user.orderId;   // string — is this a User ID or Order ID? The type system can't tell
```

String IDs are all the same type. You can accidentally pass a user ID where an order ID is expected, and TypeScript won't catch it. The type system has no idea what kind of entity `'abc123'` refers to.

## References in JustScale

In JustScale, entities reference each other with `field.ref()` — a type-safe, model-scoped reference:

TypeScript

```typescript
class Campaign extends defineModel({
  fields: {
    creator: field.ref(Creator),      // Reference<Creator>, not a string
    title: field.string().max(255),
    goalAmount: field.decimal(12, 2),
  },
}) {}

const campaign = await campaigns.findOne(Campaign.fields.title.eq('My Project'));
const creator = await campaign.creator;  // Reference is PromiseLike — just await it
```

`Reference` can only point at a Creator. You cannot pass it where a `Reference` is expected. The compiler catches the mismatch.

## Ref — The Unified Reference Type

Services don't need to know whether they're receiving a fresh reference, a loaded entity, or a locked entity. `Ref` unifies all three:

TypeScript

```typescript
type Ref<T> = Reference<T> | Persistent<T> | Lock<Persistent<T>>;

// Service accepts Ref<T> — works with any of these
async transfer(from: Ref<Account>, to: Ref<Account>, amount: number) {
  // Framework resolves the ref to a Persistent<Account> internally
}

// All of these work:
await transfer(fromAccount, toAccount, 100);           // pass entities directly
await transfer(Account.ref`${id}`, toAccount, 100);   // or a typed reference
```

💡Tip

A persistent entity IS a valid reference. You don't need to extract an ID and wrap it — just pass the entity itself.

## Boundary Conversion

Raw strings only enter the system at boundaries — controllers, process paths, external API calls. The preferred conversion uses `.types({ Model })` on routes and processes so the param is already a `Ref` inside the handler:

TypeScript

```typescript
// In a controller — param name matches the model key (lowercased),
// so .types({ User }) gives you params.user as Ref<User> directly
Get('/:user')
  .types({ User })
  .handle(({ params }) => userService.getProfile(params.user));

// In a process — same pattern
createProcess({
  path: '/campaign/:campaign/lifecycle',
  types: { Campaign },
  async handler({ campaigns }, { campaign }) {
    // campaign is Ref<Campaign>
    const found = await campaigns.get(campaign);
  },
});
```

When you *do* get a raw string from an external boundary (a webhook payload, a CLI arg, etc.), convert it with the callable form or a tagged template:

TypeScript

```typescript
const userRef = User.ref(someId);          // call form
const userRef2 = User.ref`${someId}`;       // tagged template form (same result)
```

Domain code never sees strings. Boundary code converts once, at the edge.

## The Escape Hatch

Sometimes infrastructure truly needs a raw identifier — for URLs, external APIs, or logging. The escape hatch is deliberately awkward:

TypeScript

```typescript
// Deliberately verbose — signals you're leaving the domain
const rawId = Model.ref(entity).identifier;
```

The awkwardness is a feature. If you're reaching for `.identifier`, you should be asking whether you really need the raw value, or whether you can keep working with typed references.

## Nominal Identity Preserved

`Reference` and `Persistent` preserve the real class — not just its fields. When you extend `defineModel` and add body getters or methods, those members are visible on awaited references. `Model.ref` uses polymorphic `this`, so subclasses keep their own identity throughout the type chain.

TypeScript

```typescript
class Customer extends defineModel({
  fields: {
    firstName: field.string(),
    lastName: field.string(),
  },
}) {
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

class Post extends defineModel({
  fields: {
    author: field.ref(Customer),
    title: field.string(),
  },
}) {}

// post.author is Reference<Customer>, not Reference<Model>
const author = await post.author;     // Persistent<Customer>
author.fullName;                      // ✓ typed, the getter is visible
```

In other words: your domain methods travel with the reference. No loss of type information when a model is pulled out through a `field.ref` edge.

💡Tip

At HTTP or CLI boundaries, validate incoming refs with `z.ref(Model)` — exported from `@justscale/core/models`. It produces a Zod schema that decodes a raw identifier into a `Reference`:

TypeScript

```typescript
import { z } from 'zod';
import { field } from '@justscale/core/models';

const CreatePostBody = z.object({
  title: z.string(),
  author: z.ref(Author).optional(),
});
```

The callable `User.ref(someId)` / `User.ref\`$${someId}\`` form is unchanged — still the way to convert a raw string into a typed reference inside handler code. What went away is passing `User.ref` itself as a Zod schema; use `z.ref(User)` at route boundaries instead.

## Next Steps

- References
- Models Overview
- Type States
