Skip to main content

Immutable Builder

Deep dive into immutable state and how the functional builder works under the hood.

What Is Immutability?

Immutability means once you create an object, you can't change it. To "change" it, you create a new object with the modifications.

Mutable vs Immutable

// Mutable (changes the original)
const user = { name: 'Alice' };
user.name = 'Bob'; // Original object is modified
console.log(user); // { name: 'Bob' }

// Immutable (creates new object)
const user1 = { name: 'Alice' };
const user2 = { ...user1, name: 'Bob' }; // New object
console.log(user1); // { name: 'Alice' } - unchanged!
console.log(user2); // { name: 'Bob' }

Creating an Immutable Builder

Syntax

const builder = createImmutableBuilder<T>(keys, schema?);

Parameters:

  • keys - Array of property names (required)
  • schema - Optional Zod schema for validation

Basic Example

import { createImmutableBuilder } from '@noony-serverless/type-builder';

interface User {
id: number;
name: string;
email: string;
}

const userBuilder = createImmutableBuilder<User>(['id', 'name', 'email']);

With Zod Validation

import { z } from 'zod';

const UserSchema = z.object({
id: z.number().positive(),
name: z.string().min(2),
email: z.string().email(),
});

const userBuilder = createImmutableBuilder<User>(
['id', 'name', 'email'],
UserSchema // Validates on build()
);

Builder API

Every immutable builder has three core methods:

1. empty() - Create Empty State

Returns an empty builder state (empty object).

const state = userBuilder.empty();
// Returns: {}

2. withX(value) - Curried Setters

For each property, you get a withX method that's curried (takes value, returns function).

// withId is curried: (value: number) => (state) => newState
const setId1 = userBuilder.withId(1); // Returns a function

const state1 = userBuilder.empty();
const state2 = setId1(state1); // Apply the function

console.log(state1); // {} - unchanged
console.log(state2); // { id: 1 }

All-in-one:

const state = userBuilder.withId(1)(userBuilder.empty());
// { id: 1 }

3. build(state) - Create Final Object

Validates (if schema provided) and returns the final object.

const state = { id: 1, name: 'Alice', email: 'alice@example.com' };
const user = userBuilder.build(state);
// Returns: User (validated if schema provided)

Understanding Builder State

What Is BuilderState?

type BuilderState<T> = Readonly<Partial<T>>;

It's just a readonly partial object. For User:

type BuilderState<User> = Readonly<
Partial<{
id: number;
name: string;
email: string;
}>
>;

Examples:

const state1: BuilderState<User> = {};
const state2: BuilderState<User> = { id: 1 };
const state3: BuilderState<User> = { id: 1, name: 'Alice' };
const state4: BuilderState<User> = { id: 1, name: 'Alice', email: 'alice@example.com' };

Why Readonly?

To enforce immutability at compile time:

const state: BuilderState<User> = { id: 1 };
state.id = 2; // ❌ TypeScript error: Cannot assign to 'id' because it is read-only

Step-by-Step Building

Let's build a user object step by step, inspecting state at each step:

const userBuilder = createImmutableBuilder<User>(['id', 'name', 'email']);

// Step 1: Empty state
const state1 = userBuilder.empty();
console.log('State 1:', state1); // {}

// Step 2: Add id
const state2 = userBuilder.withId(1)(state1);
console.log('State 2:', state2); // { id: 1 }
console.log('State 1 unchanged:', state1); // {} - still empty!

// Step 3: Add name
const state3 = userBuilder.withName('Alice')(state2);
console.log('State 3:', state3); // { id: 1, name: 'Alice' }

// Step 4: Add email
const state4 = userBuilder.withEmail('alice@example.com')(state3);
console.log('State 4:', state4); // { id: 1, name: 'Alice', email: '...' }

// Step 5: Build
const user = userBuilder.build(state4);
console.log('Final user:', user);

Output:

State 1: {}
State 2: { id: 1 }
State 1 unchanged: {}
State 3: { id: 1, name: 'Alice' }
State 4: { id: 1, name: 'Alice', email: 'alice@example.com' }
Final user: { id: 1, name: 'Alice', email: 'alice@example.com' }

Key Observation: Every state is a different object. state1 !== state2 !== state3 !== state4.


How Setters Work (Under the Hood)

When you call withX(value), here's what happens:

// Simplified implementation
function withName(name: string) {
return (state: BuilderState<User>) => {
// Create new object with spread operator
const newState = { ...state, name };

// Freeze to prevent mutations
return Object.freeze(newState);
};
}

The Magic:

  1. { ...state, name } creates a new object
  2. Object.freeze() makes it immutable
  3. Returns a function (curried)

Why Currying?

Setters are curried so they work with pipe and compose:

import { pipe } from '@noony-serverless/type-builder';

// Without currying (doesn't work)
const state = pipe(
userBuilder.withId(1, state), // ❌ Doesn't work - needs state!
userBuilder.withName('Alice', state) // ❌ Doesn't work
);

// With currying (works!)
const transform = pipe(
userBuilder.withId(1), // Returns function
userBuilder.withName('Alice') // Returns function
);

const state = transform(userBuilder.empty()); // Apply to empty state

Currying makes composition possible.


Validation with Zod

When you provide a Zod schema, build() validates the state:

import { z } from 'zod';

const UserSchema = z.object({
id: z.number().positive(),
name: z.string().min(2),
email: z.string().email(),
});

const userBuilder = createImmutableBuilder<User>(['id', 'name', 'email'], UserSchema);

// ✅ Valid - build succeeds
const validUser = userBuilder.build({
id: 1,
name: 'Alice',
email: 'alice@example.com',
});

// ❌ Invalid - build throws
try {
const invalidUser = userBuilder.build({
id: -1, // Negative (fails .positive())
name: 'A', // Too short (fails .min(2))
email: 'not-an-email', // Invalid (fails .email())
});
} catch (error) {
console.error('Validation failed:', error);
}

Benefits:

  • ✅ Catches invalid data before object creation
  • ✅ Same Zod error messages
  • ✅ Type-safe validation

Immutability Guarantees

1. State Never Mutates

const state1 = userBuilder.empty();
const state2 = userBuilder.withId(1)(state1);

console.log(state1 === state2); // false (different objects)
console.log(state1); // {} (unchanged)

2. Frozen Objects

const state = userBuilder.withId(1)(userBuilder.empty());

try {
state.id = 2; // ❌ Error in strict mode
state.name = 'Alice'; // ❌ Error in strict mode
} catch (error) {
console.error('Cannot mutate frozen object');
}

3. Safe Sharing

const state = userBuilder.withId(1)(userBuilder.empty());

function someFunction(s: BuilderState<User>) {
// Can't modify s, can only create new state
return userBuilder.withName('Alice')(s);
}

const newState = someFunction(state);
console.log(state); // { id: 1 } - unchanged
console.log(newState); // { id: 1, name: 'Alice' }

Performance Considerations

Memory Usage

Each setter creates a new object, which uses more memory:

// OOP (1 builder object, mutated)
const builder = createBuilder<User>();
builder.withId(1); // Mutates existing object
builder.withName('Alice'); // Mutates existing object
builder.withEmail('alice@example.com'); // Mutates existing object
// Memory: 1 object allocated

// FP (4 state objects)
const state1 = userBuilder.empty(); // Object 1
const state2 = userBuilder.withId(1)(state1); // Object 2
const state3 = userBuilder.withName('Alice')(state2); // Object 3
const state4 = userBuilder.withEmail('alice@example.com')(state3); // Object 4
// Memory: 4 objects allocated

Impact:

  • ~2x more memory per build
  • Modern garbage collectors handle this well
  • Not an issue for most applications

Speed Comparison

// Benchmark: Building 10,000 users

// OOP Builder: ~2.5ms (400,000 ops/sec)
// FP Builder: ~6.7ms (150,000 ops/sec)
// Difference: 2.6x slower

Is this slow? No! 150k ops/sec is 6.6 microseconds per operation.

When to Optimize

Only optimize if:

  1. Profiling shows this is a bottleneck
  2. You're building 10,000+ objects/second
  3. Memory is severely constrained

For 99% of use cases, the immutability benefits outweigh the cost.


Best Practices

1. Reuse Builder Instances

// ✅ Good - create once, reuse
const userBuilder = createImmutableBuilder<User>(['id', 'name', 'email']);

function createUser1() {
return userBuilder.build(
pipe(userBuilder.withId(1), userBuilder.withName('Alice'))(userBuilder.empty())
);
}

function createUser2() {
return userBuilder.build(
pipe(userBuilder.withId(2), userBuilder.withName('Bob'))(userBuilder.empty())
);
}

// ❌ Bad - creates new builder every time
function createUser() {
const builder = createImmutableBuilder<User>(['id', 'name', 'email']); // Wasteful!
return builder.build(pipe(builder.withId(1), builder.withName('Alice'))(builder.empty()));
}

2. Use Pipe for Readability

// ❌ Hard to read
const user = userBuilder.build(
userBuilder.withEmail('alice@example.com')(
userBuilder.withName('Alice')(userBuilder.withId(1)(userBuilder.empty()))
)
);

// ✅ Clear and readable
const user = userBuilder.build(
pipe(
userBuilder.withId(1),
userBuilder.withName('Alice'),
userBuilder.withEmail('alice@example.com')
)(userBuilder.empty())
);

3. Extract Common Patterns

// ✅ Reusable defaults
const adminDefaults = pipe(userBuilder.withRole('admin'), userBuilder.withActive(true));

const admin = userBuilder.build(
pipe(adminDefaults, userBuilder.withId(1), userBuilder.withName('Admin'))(userBuilder.empty())
);

4. Custom Transformations

// ✅ Extract custom logic
const normalizeEmail = (state: BuilderState<User>) => {
if (state.email) {
return Object.freeze({
...state,
email: state.email.toLowerCase().trim(),
});
}
return state;
};

const user = userBuilder.build(
pipe(
userBuilder.withEmail(' ALICE@EXAMPLE.COM '),
normalizeEmail // Clean transformation
)(userBuilder.empty())
);

Common Patterns

Pattern 1: Factory Functions

function createUser(id: number, name: string, email: string): User {
return userBuilder.build(
pipe(
userBuilder.withId(id),
userBuilder.withName(name),
userBuilder.withEmail(email)
)(userBuilder.empty())
);
}

const alice = createUser(1, 'Alice', 'alice@example.com');
const bob = createUser(2, 'Bob', 'bob@example.com');

Pattern 2: Partial Updates

function updateEmail(user: User, newEmail: string): User {
return userBuilder.build(
pipe(
// Start with existing user data
() => ({ ...user }),
userBuilder.withEmail(newEmail)
)()
);
}

const user = { id: 1, name: 'Alice', email: 'old@example.com' };
const updated = updateEmail(user, 'new@example.com');

console.log(user); // { id: 1, name: 'Alice', email: 'old@example.com' } - unchanged
console.log(updated); // { id: 1, name: 'Alice', email: 'new@example.com' }

Pattern 3: Conditional Building

function createUser(data: { name: string; isAdmin: boolean }): User {
return userBuilder.build(
pipe(
userBuilder.withId(generateId()),
userBuilder.withName(data.name),
data.isAdmin ? userBuilder.withRole('admin') : userBuilder.withRole('user')
)(userBuilder.empty())
);
}

Debugging

Inspect State at Any Point

import { tap } from '@noony-serverless/type-builder';

const user = userBuilder.build(
pipe(
userBuilder.withId(1),
tap((state) => console.log('After withId:', state)),
userBuilder.withName('Alice'),
tap((state) => console.log('After withName:', state)),
userBuilder.withEmail('alice@example.com'),
tap((state) => console.log('After withEmail:', state))
)(userBuilder.empty())
);

// Output:
// After withId: { id: 1 }
// After withName: { id: 1, name: 'Alice' }
// After withEmail: { id: 1, name: 'Alice', email: 'alice@example.com' }

Time-Travel Debugging

const history: BuilderState<User>[] = [];

const user = userBuilder.build(
pipe(
userBuilder.withId(1),
tap((s) => history.push(s)),
userBuilder.withName('Alice'),
tap((s) => history.push(s)),
userBuilder.withEmail('alice@example.com'),
tap((s) => history.push(s))
)(userBuilder.empty())
);

console.log('State history:', history);
// [
// { id: 1 },
// { id: 1, name: 'Alice' },
// { id: 1, name: 'Alice', email: 'alice@example.com' }
// ]

Summary

Key Takeaways

  1. Immutability - Every transformation returns a new object
  2. Currying - Setters return functions for composability
  3. Type Safety - Full TypeScript support with readonly guarantees
  4. Validation - Optional Zod schema validation
  5. Performance - 2-3x slower than OOP, but still very fast (150k ops/sec)

When to Use

Use Immutable Builder when:

  • Building complex state transformations
  • Need guaranteed immutability (React/Redux)
  • Want reusable transformation patterns
  • Testing is important

⚠️ Consider OOP Builder when:

  • Simple object construction
  • Maximum performance critical
  • Hot paths (10,000+ calls/sec)

Next Steps