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:
{ ...state, name }creates a new objectObject.freeze()makes it immutable- 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:
- Profiling shows this is a bottleneck
- You're building 10,000+ objects/second
- 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
- Immutability - Every transformation returns a new object
- Currying - Setters return functions for composability
- Type Safety - Full TypeScript support with readonly guarantees
- Validation - Optional Zod schema validation
- 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
- 🔄 Pipe and Compose - Learn function composition
- 🎨 Higher-Order Functions - Map, filter, fold operations
- 📚 Real-World Examples - Practical applications