What you’ll learn

  • How to read entity state through props
  • What auto-setters are generated and why they’re typed
  • When auto-setters are enough — and when you need an action
  • How entity identity comparison works

Reading state

const user = User.create({ id: 'b3f1...', username: 'alice' });

user.props.username;       // 'alice'
user.props.emailVerified;  // false

props returns a frozen snapshot. You can read any field, but you can’t mutate it directly:

user.props.username = 'hacker'; // TypeError: Cannot assign to read only property

The object is Object.freeze()’d. This is a debug-time guard — not a security boundary. It catches accidental mutation, which is the most common source of “how did this value change?” bugs.

Storing props in a local variable and expecting updates:

const { username } = user.props;
user.actions.setUsername('alicia');
username; // still 'alice' — you destructured a snapshot

props is a snapshot, not a reactive reference. Always read user.props.username after mutations if you need the current value.

Mutating nested objects/arrays from props:

user.props.tags.push('admin'); // mutates the frozen snapshot? No — but don't rely on this

Frozen objects don’t freeze nested arrays. Don’t mutate anything from props. Use actions (or auto-setters for flat fields). For nested mutations, see Actions.

Auto-setters — the default for simple mutation

Every Zod field (except id) gets a typed auto-setter:

const UserSchema = z.object({
  id: z.string().uuid(),
  username: z.string(),
  age: z.number(),
  status: z.enum(['active', 'inactive']),
});

// Auto-generated, fully typed:
user.actions.setUsername('alicia');   // string → ✓
user.actions.setAge(30);              // number → ✓
user.actions.setStatus('inactive');   // 'active' | 'inactive' → ✓
user.actions.setAge('30');            // TypeScript error: not a number
user.actions.setStatus('banned');     // TypeScript error: not in enum

Auto-setters are discovered from schema.shape. They always exist for Zod/Valibot schemas, no trial validation needed.

Writing manual setters as actions:

actions: {
  setName(state, name: string) { state.name = name; },
  setAge(state, age: number) { state.age = age; },
  setStatus(state, s: string) { state.status = s; },
}

Three actions that do what auto-setters already do. Delete them. The user writes user.actions.setName('alice') regardless — you just moved the definition into your entity config for no benefit.

When auto-setters ARE enough

A surprising amount of domain logic is just “set this field to that value”:

// All handled by auto-setters:
user.actions.setBio('new bio');
user.actions.setAvatarUrl('https://...');
user.actions.setLastLoginAt(new Date());
user.actions.setProfileComplete(true);

Each one is a single assignment. No validation, no guards, no multi-field changes. Auto-setters handle every one without you writing a line.

When auto-setters are NOT enough

Auto-setters are pure assignment. You need an Action when:

SituationTool
Guard clause before mutationAction
Multi-field change (atomic)Action
Domain event emissionAction (Aggregate)
Validation beyond ZodAction
State machine transition logicAction

Identity and equality

Entities compare by identity, not by props:

const a = User.create({ id: 'abc', username: 'alice' });
const b = User.create({ id: 'abc', username: 'bob' });

a.equals(b);   // true — same id
a === b;       // false — different object references

Comparing entities with ===:

if (userA === userB) { ... } // compares object identity, not entity identity

Use .equals() for entity comparison. === checks if it’s literally the same object.


What you’ve learned

props is a frozen snapshot. Read any field, but never mutate it directly. Always read user.props.field after mutations — destructuring captures a stale value.

Auto-setters handle simple field assignment. Every Zod field gets a typed setField() that TypeScript enforces. If your action body is a single assignment, delete it — the auto-setter already does the job.

Auto-setters are pure. No validation, no guards, no events. When you need any of those, you need an Action.

Entities compare by identity. userA.equals(userB) checks the id field. === checks object reference — two entities with the same id are equal even if they’re different objects.

What’s next?

Auto-setters cover simple field assignment. But domain logic needs more — guard clauses, multi-field changes, state machine transitions. Actions covers when and how to write actions that carry business meaning.

Actions