Skip to content

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
// 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
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<Creator> can only point at a Creator. You cannot pass it where a Reference<Order> is expected. The compiler catches the mismatch.

Ref<T> — 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<T> unifies all three:

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<T> inside the handler:

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
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
// 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<User> and Persistent<User> 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
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<Model>:

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.