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. At the boundary, you convert them to typed references with tagged templates:

TypeScript
// In a process handler — convert path parameter to typed ref
async handler({ campaigns }, [campaignId]) {
  const campaignRef = Campaign.ref`${campaignId}`;
  const campaign = await campaigns.get(campaignRef);
  // From here on, everything is type-safe — no strings
}

// In a controller — same pattern
Get('/:userId').handle(({ params }) => {
  const userRef = User.ref`${params.userId}`;
  return userService.getProfile(userRef);
})

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.