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
propsis a snapshot, not a reactive reference. Always readuser.props.usernameafter 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:
| Situation | Tool |
|---|---|
| Guard clause before mutation | Action |
| Multi-field change (atomic) | Action |
| Domain event emission | Action (Aggregate) |
| Validation beyond Zod | Action |
| State machine transition logic | Action |
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