Lightweight, zero-dependency object builder for tests and production use cases.
Break free from boilerplate and build expressive objects in seconds. Compose fluent custom builders that capture business language, keep teams aligned, and leave room for playful experimentation.
object-builder pairs auto-generated with... helpers with your own custom builders, so complex updates stay type-safe without sacrificing readability—whether you're shaping fixtures, seeds, or production data flows. It works seamlessly in TypeScript and plain JavaScript projects alike.
- ✅ Fluent API keeps data tweaks in a single expression.
- ✅ Custom builders unlock richer scenarios.
- ✅ Defaults live in one place; every property stays valid until you change it.
- ✅ Callers do not need to remember the shape of nested objects.
- ✅ TypeScript surfaces breaking changes immediately, so maintenance stays simple.
- ✅ No more forgotten
return this; every builder call is chainable. - ✅ It's immutable - your original objects stays unchanged and every built object is independent.
- ✅ It's composable - use multiple builders for nested objects.
- ✅ Powerful helper for tests - define defaults and only change what matters for give test case.
bun add @jjanczyk/object-builder
# or
pnpm add @jjanczyk/object-builder
# or
npm install @jjanczyk/object-builder
# or
yarn add @jjanczyk/object-builderThese snippets show the main patterns you'll reach for every day.
Start with a baseline object and layer on fluent helpers that express domain language.
import builder from '@jjanczyk/object-builder'
type Message = {
id: string
name: string
type: 'simple' | 'rich'
metadata?: Record<string, string>
}
const aMessage = () => builder<Message>(
{
id: 'abc',
name: 'Test Message',
type: 'simple',
},
(message) => ({
withPriorityAlert(priority: 'high' | 'low', source: string) {
message.type = 'rich'
message.metadata = {
...(message.metadata ?? {}),
severity: priority,
source,
}
},
withMetadataPair(key: string, value: string) {
message.metadata = { ...(message.metadata ?? {}), [key]: value }
},
})
)
const scheduledMessage = aMessage()
.withId('abc')
.withName('Alert')
.withPriorityAlert('high', 'monitoring-service')
.withMetadataPair('ticket', 'INC-123')
.build()
// Result:
// { id: 'abc', name: 'Alert', type: 'rich', metadata: { severity: 'high', source: 'monitoring-service', ticket: 'INC-123' } }The generated with... methods cover simple assignments, while your bespoke helpers coordinate multi-field changes like alert priorities and metadata.
Need only the auto-generated mutators? Provide defaults and use the fluent API as-is.
import builder from '@jjanczyk/object-builder'
const todoBuilder = builder({
id: 'todo-1',
title: 'Write docs',
done: false,
})
const urgentTodo = todoBuilder
.withTitle('Ship release notes')
.withDone(true)
.build()
// Result:
// { id: 'todo-1', title: 'Ship release notes', done: true }Every property on the initial object unlocks a corresponding with... helper for quick tweaks without custom code.
Have some nested objects that needs to be built? Simply use multiple builders!
import {builder} from "@jjanczyk/builder";
type Message = {
id: string
text: string
}
type Chat = {
id: string
title: string
messages: Message[]
}
const aMessage = () => builder<Message>({id: '1', text: 'example'})
const aChat = () => builder<Chat>({id: '1', title: 'example', messages: []})
const chat = aChat()
.withTitle('My chat')
.withMessages([
aMessage()
.withText('My message')
.build()
])
.build()
// Result:
// { id: '1', title: 'My chat', messages: [{ id: '1', text: 'My message' }] }You can skip the initial object entirely and fill everything through the fluent API.
import builder from '@jjanczyk/object-builder'
type Account = {
id: string
email: string
role?: 'user' | 'admin'
}
const account = builder<Account>()
.withId('user-123')
.withEmail('user@example.com')
.withRole('admin')
.build()
// Result:
// { id: 'user-123', email: 'user@example.com', role: 'admin' }When you omit initialData, or pass incomplete data, TypeScript will hide .build() until all required fields are set. In JavaScript, just make sure you set the required fields before calling .build() so the result includes everything you expect.
Need several copies of the same setup—for example, to seed arrays or prepare fixtures? Use buildList(count) to produce any number of independent objects.
import builder from '@jjanczyk/object-builder'
const userBuilder = builder({
id: 'user-1',
email: 'user@example.com',
active: true,
})
const threeUsers = userBuilder.withActive(false).buildList(3)
// each user has unique object references:
threeUsers[0] !== threeUsers[1] // true
// Result:
// [
// { id: 'user-1', email: 'user@example.com', active: false },
// { id: 'user-1', email: 'user@example.com', active: false },
// { id: 'user-1', email: 'user@example.com', active: false },
// ]Need to tweak each item? Pass an optional mapper that receives the fresh clone and index.
import builder from '@jjanczyk/object-builder'
const userBuilder = builder({
id: 'user-1',
email: 'user@example.com',
active: true,
})
const customizedUsers = userBuilder.buildList(3, (user, index) => {
user.id = `user-${index + 1}`
user.email = `user-${index + 1}@example.com`
user.active = index === 0
})
// each user has unique object references:
customizedUsers[0] !== customizedUsers[1] // true
customizedUsers[0].id // "user-1"
// Result:
// [
// { id: 'user-1', email: 'user-1@example.com', active: true },
// { id: 'user-2', email: 'user-2@example.com', active: false },
// { id: 'user-3', email: 'user-3@example.com', active: false },
// ]builder(initialData?, customBuilders?)deep-copiesinitialData(when provided) and returns a fluent proxy..with<Property>(value)is generated for every key ininitialData..from(otherObject)resets the builder to another object (copied to keep data isolated)..build()returns the accumulated object..buildList(count, customizer?)returnscountindependent copies of the current builder state and lets you tweak each item viamapFn(item, index).builder<Type>()lets you start from scratch and add properties via fluent calls.customBuilders(target)lets you define additional fluent helpers. They receive the mutable target so you can coordinate multiple fields and validations.- Works in JavaScript: import
builderand use the same fluent API; the proxy enforceswith...naming so the ergonomics match your TypeScript usage.
Because everything runs through a Proxy, every helper returns the builder automatically—no need to remember return this. TypeScript infers the allowed methods from the initial data and your custom helpers, so misuse is caught at compile time. Custom helpers that coordinate several fields (like withPriorityAlert) make the builder especially powerful for non-trivial scenarios.
- Add new fields to
initialDatato expose morewith...methods. - Keep custom builder helpers idempotent or document side effects—they execute directly against the target object.
- Validate inputs inside custom builders before mutating the target to avoid leaking invalid state.
- Share reusable defaults by exporting factory functions (
export const aMessage = () => builder(...)) and reusing the fluent helpers wherever you need them.
This package was developed with bun
bun installinstalls dependenciesbun testruns testsbun lintruns lint checkbun fixfixes lint/format issues