Working with Stories
A progressive tutorial on composing coherent, related mock datasets with storymock stories.
What is a Story?
A story composes multiple schema instances into a coherent, typed record — a snapshot of related data where foreign keys match, arrays are wired, and everything fits together. Think of it as "these objects exist together in the same world."
Stories are pure data snapshots, not state machines. Traits handle states ('deleted', 'expired', 'admin'). Your application code handles transitions. A story just gives you the data at a point in time.
import { story, ref } from 'storymock';
const checkout = story()
.add('user', UserSchema)
.add('order', OrderSchema, { userId: ref('user') })
.create();
// checkout.user is a User
// checkout.order is an Order
// checkout.order.userId === checkout.user.id ✓Building a Story
Start with story(), then add entries with .add() and .addMany():
const s = story()
.add('user', UserSchema) // single entry
.addMany('items', ItemSchema, 3) // array of 3 entries
.create();
// s: { user: User; items: Item[] }TypeScript accumulates the record type as you chain calls — each .add() widens the type:
story() // Story<{}>
.add('user', UserSchema) // Story<{ user: User }>
.addMany('items', ItemSchema, 3) // Story<{ user: User; items: Item[] }>
.create() // { user: User; items: Item[] }Every field is fully typed — s.user is User, s.items is Item[]. No casts needed.
Cross-References: ref()
The most common relationship pattern is a foreign key: order.userId should equal user.id. That's what ref() does.
Pass it as an inline override in .add():
const s = story()
.add('user', UserSchema)
.add('order', OrderSchema, { userId: ref('user') })
.create();
// s.order.userId === s.user.id ✓How ref() resolves
ref('user') goes through this chain to find the identity:
.id()accessor — if the schema has.id(fn), callsfn(instance)and uses the return valueidproperty — if the generated object has a property namedid, uses its value- String value — if the entire generated value is a string, uses it directly
- Error — throws
UnknownRefError
Most schemas have an id field, so ref() usually just works.
Referencing a specific field
By default ref() resolves to the entry's identity. To reference a different field:
.add('notification', NotificationSchema, {
recipientEmail: ref('user', 'email'), // resolves to user.email
})ref() with arrays
When ref() points at an addMany entry, it resolves to an array of IDs:
const s = story()
.addMany('songs', SongSchema, 5)
.add('playlist', PlaylistSchema, { songIds: ref('songs') })
.create();
// s.playlist.songIds === [songs[0].id, songs[1].id, ..., songs[4].id]When ref() appears in an addMany inline override, the same value is used for every entry:
story()
.add('user', UserSchema)
.addMany('orders', OrderSchema, 3, { userId: ref('user') })
.create();
// All 3 orders get the same userId (equal to user.id)If you need per-entry variation (e.g., distributing orders across different users), use .setup() instead.
Type pitfall
ref() on an addMany entry resolves to an array, not a single value. If your field type is a scalar (e.g., postId: string), use .setup() to pick one item instead:
// ❌ Type mismatch — ref('posts') resolves to string[]
.add('comment', CommentSchema, { postId: ref('posts') })
// ✅ Use setup to pick one
.setup((m) => { m.comment.postId = m.posts[0].id; })Wiring with .setup()
ref() handles scalar foreign keys beautifully, but some relationships need more: array membership, computed values, or distributing items across collections. That's where .setup() comes in.
.setup() records a callback — no mutation happens when you call it. At .create() time, all setup callbacks execute sequentially on the generated objects.
const checkout = story()
.add('user', UserSchema)
.addMany('items', ItemSchema, 2)
.add('coupon', CouponSchema)
.setup((m) => {
m.user.items = m.items; // wire the array
m.user.coupons = [m.coupon]; // wire another array
m.coupon.userId = m.user.id; // wire a foreign key
})
.create();Setup accumulation
Multiple .setup() calls accumulate — they all run, in order. This is essential for story inheritance:
const base = story()
.add('user', UserSchema)
.addMany('items', ItemSchema, 2)
.setup((m) => { m.user.items = m.items; });
const withCoupon = base
.add('coupon', CouponSchema)
.setup((m) => {
m.user.coupons = [m.coupon];
m.coupon.userId = m.user.id;
});
// At .create() time, BOTH setups run:
// 1. m.user.items = m.items
// 2. m.user.coupons = [m.coupon]; m.coupon.userId = m.user.idThe base story's wiring is never lost — derived stories just add more on top.
When to Use ref() vs .setup()
| Pattern | Use | Example |
|---|---|---|
| Scalar foreign key | ref() | { userId: ref('user') } |
| Array membership | .setup() | m.user.items = m.items |
| Complex/computed wiring | .setup() | Alternating senders, distributing members |
ref() handles roughly 70% of relationship wiring. For the rest, .setup() gives you full JavaScript to express whatever you need.
// ref() — clean and declarative
.add('order', OrderSchema, { userId: ref('user') })
// .setup() — full control for complex cases
.setup((m) => {
m.messages.forEach((msg, i) => {
msg.senderId = i % 2 === 0 ? m.alice.id : m.bob.id;
});
})Customizing Entries: .with()
On stories, .with() takes the entry name as the first argument, followed by traits and/or overrides:
Apply a trait to an entry
const birthdayStory = baseStory.with('user', 'birthday');Apply field overrides
const namedStory = baseStory.with('user', { name: 'Eldar' });Combine traits and overrides
const specific = baseStory.with('user', 'birthday', { name: 'Eldar' });Effect on addMany entries
When .with() targets an addMany entry, the trait or override applies to all items by default:
const allDeleted = orgStory.with('teams', 'deleted');
// Every team has the 'deleted' traitTo target a specific item by index:
baseStory.with('items[0]', 'premium'); // only the first item
baseStory.with('items[2]', { price: 9999 }); // only the third itemStory Inheritance
This is where stories become a superpower. Because every method returns a new story, you can build a tree of increasingly specific scenarios from a shared base:
// --- Base story: an org with teams ---
const orgBase = story()
.add('org', OrgSchema)
.addMany('teams', TeamSchema, 2)
.setup((m) => {
m.teams.forEach(t => { t.orgId = m.org.id; });
});
// --- Extended: add members to the org ---
const orgWithMembers = orgBase
.addMany('members', MemberSchema, 6)
.setup((m) => {
m.members.forEach((u, i) => {
const team = m.teams[i % m.teams.length];
u.teamId = team.id;
});
});
// --- Test variants: zero re-wiring ---
const deletedOrg = orgWithMembers.with('org', 'deleted');
const enterpriseOrg = orgWithMembers.with('org', 'enterprise');Full example
See examples/story-inheritance.ts for the complete example including reusable wiring functions, cascading .with(), and index targeting.
Why this works
.with(),.add(),.addMany(), and.setup()all return new stories.setup()callbacks accumulate — derived stories inherit all parent setups- The base wiring is defined once and carries through every variant
- Test-specific stories only express what's different
This pattern — define once, reuse everywhere — eliminates the duplicated wiring logic that makes test fixtures painful to maintain.
Story-Level derive()
Sometimes you need to compute additional data from the generated mocks. Story-level derive() runs after all .setup() callbacks, so it sees the fully-wired state:
const s = story()
.add('user', UserSchema)
.add('item', ItemSchema)
.derive('receipt', (mocks) => ({
buyer: mocks.user.name,
product: mocks.item.name,
total: mocks.item.price,
date: new Date(),
}))
.create();
// s.receipt: { buyer: string; product: string; total: number; date: Date }The derived entry becomes part of the typed record, just like any .add() entry.
Reusable Wiring Functions
When the same wiring logic appears across multiple stories, extract it into a plain function and pass it to .setup():
const wireOrgTeams = (m: { org: Org; teams: Team[] }) => {
m.teams.forEach(t => { t.orgId = m.org.id; });
};
// Reuse in any story that has 'org' and 'teams':
const small = story().add('org', OrgSchema).addMany('teams', TeamSchema, 2).setup(wireOrgTeams);
const large = story().add('org', OrgSchema).addMany('teams', TeamSchema, 5).setup(wireOrgTeams);Full example
See examples/story-inheritance.ts for the complete pattern with multiple reusable wiring functions.
Full API →
This guide covered the main patterns for composing stories. For the complete method reference — including seeding, batch generation, and detailed resolution rules — see the Story API Reference.