@opentf/immutate
v0.1.0
Published
A lightweight, high-performance immutability library for JavaScript objects.
Maintainers
Readme
@opentf/immutate
Part of the Open Tech Foundation ecosystem.
🚀 A lightweight, high-performance immutability library for JavaScript objects.
✨ Features
- Blazing Fast: Fastest in class — wins all benchmarks against immer, mutative, structura, and craft.
- Zero Dependencies: No runtime dependencies. Pure structural-sharing engine.
- Simple API: Write mutable-like syntax, get immutable results.
- Structural Sharing: Only copies the changed spine — unchanged branches are reused by reference.
- Map & Set Support: Full draft support for
MapandSet— deep modifications, iteration, and structural sharing. - TypeScript Support: Full type safety with
Draft<T>andImmutable<T>(deep readonly). - Async Support:
immutateAsyncfor async recipes.
🚀 Installation
# npm
npm install @opentf/immutate
# pnpm
pnpm add @opentf/immutate
# bun
bun add @opentf/immutate📖 Usage
immutate allows you to write code that looks like you are mutating your data, but it actually returns a new immutable version of it.
Basic Example
import { immutate } from '@opentf/immutate';
const baseState = {
todo: 'Learn Immutate',
done: false
};
const nextState = immutate(baseState, (draft) => {
draft.done = true;
});
console.log(baseState.done); // false
console.log(nextState.done); // trueComplex Updates
const users = [
{ id: 1, name: 'Alice', active: true },
{ id: 2, name: 'Bob', active: false }
];
const updatedUsers = immutate(users, (draft) => {
draft[1].active = true;
draft.push({ id: 3, name: 'Charlie', active: true });
});Resetting State (Return Pattern)
You can replace the entire state by returning a value from the recipe.
const initialState = { count: 0 };
const state = { count: 100 };
const resetState = immutate(state, (draft) => {
if (shouldReset) {
return initialState;
}
draft.count++;
});Async Recipe
const nextState = await immutateAsync(state, async (draft) => {
const data = await fetchData();
draft.items = data;
});TypeScript Support
Immutate provides full type safety out of the box. It uses Draft<T> to make the draft mutable inside the recipe, and Immutable<T> to enforce compile-time immutability on the returned state.
This approach provides protection without the performance penalty of runtime Object.freeze.
import { immutate, type Immutable } from '@opentf/immutate';
interface State {
user: { name: string };
}
const state: State = { user: { name: 'John' } };
const nextState = immutate(state, (draft) => {
draft.user.name = 'Jane'; // ✅ Draft is mutable
});
// nextState is Immutable<State>
// nextState.user.name = 'Bob'; // ❌ TS Error: Cannot assign to 'name' because it is a read-only property.🛡️ Safety & Best Practices
To maintain peak performance, Immutate prioritizes speed over runtime checks. Follow these guidelines to ensure state integrity:
1. Never Leak the Draft
The draft object is only valid inside the recipe function. Never assign it to a variable outside the recipe.
let leaked;
immutate(state, (draft) => {
leaked = draft; // ❌ BAD: Never do this
});
// leaked is still a "live" Proxy, but using it here can lead to memory leaks and bugs.2. Only Mutate the Draft
Do not attempt to mutate the baseState directly while inside a recipe. Always perform your changes on the draft.
3. Return vs. Mutation
You can either mutate the draft OR return a new value. If you return a value (other than undefined), it will completely replace the state, and any mutations made to the draft will be ignored.
4. Async Caution
When using immutateAsync, ensure that you don't have multiple overlapping async recipes modifying the same state from different places, as this can lead to classic race conditions (this is a general async state rule, not specific to Immutate).
5. Date Objects
Immutate does not proxy Date objects due to internal slot limitations in JavaScript. Treat Dates as immutable primitives: instead of calling .setFullYear(), replace the property with a new Date instance.
immutate(state, (draft) => {
// ❌ draft.date.setFullYear(2025); (Will throw TypeError)
draft.date = new Date("2025-01-01"); // ✅ Correct
});⚡ Benchmarks
Compared against popular immutability libraries. All libraries pass correctness verification before benchmarking. Lower avg time is better.
Environment: Bun v1.3.12 — 5,000 iterations per test (with warmup).
Deep Nested Object
Mutating a single leaf 9 levels deep: draft.a.b.c.d.e.f.g.h.i += 1
| Library | Avg Time (ms) | Perf Score | |---|---:|---:| | @opentf/immutate 🥇 | 0.00606 | 1.9x | | craft | 0.00660 | 1.8x | | structura | 0.00726 | 1.6x | | immer | 0.00919 | 1.3x | | mutative | 0.01164 | 1.0x |
Array Push (100 items)
Pushing 100 elements to an array: draft.list.push(i)
| Library | Avg Time (ms) | Perf Score | |---|---:|---:| | @opentf/immutate 🥇 | 0.05592 | 16.9x | | mutative | 0.12875 | 7.3x | | immer | 0.49668 | 1.9x | | craft | 0.54753 | 1.7x | | structura | 0.94442 | 1.0x |
Wide Object (200 keys)
Mutating 200 properties on a flat object: draft["key" + i] = i * 2
| Library | Avg Time (ms) | Perf Score | |---|---:|---:| | @opentf/immutate 🥇 | 0.04569 | 4.8x | | structura | 0.11275 | 2.0x | | mutative | 0.17016 | 1.3x | | immer | 0.19419 | 1.1x | | craft | 0.22150 | 1.0x |
Run benchmarks locally:
bun run benchmark🔬 Feature Comparison
| Feature | immutate | immer | mutative | structura | craft | |---|:---:|:---:|:---:|:---:|:---:| | Core | | | | | | | Proxy-based draft | ✅ | ✅ | ✅ | ✅ | ✅ | | Structural sharing | ✅ | ✅ | ✅ | ✅ | ✅ | | No-change referential equality | ✅ | ✅ | ✅ | ✅ | ✅ | | Async recipe support | ✅ | ⚠️¹ | ❌ | ✅ | ❌ | | Return value from recipe | ✅ | ✅ | ✅ | ✅ | ✅ | | Patches | | | | | | | Patch generation | ❌ | ✅ | ✅ | ✅ | ✅ | | Inverse patches (undo) | ❌ | ✅ | ✅ | ✅ | ❌ | | JSON Patch (RFC 6902) | ❌ | ❌ | ✅ | ✅² | ✅ | | Apply patches separately | ❌ | ✅ | ✅ | ✅ | ✅ | | Safety & Dev Ergonomics | | | | | | | Freeze returned state | ✅⁸ | ✅ | ✅³ | ⚠️⁴ | ❌ | | Frozen input detection | ❌ | ✅ | ✅ | ❌ | ❌ | | Draft revocation after use | ❌ | ✅ | ❌ | ❌ | ❌ | | Circular reference detection | ❌ | ❌ | ⚠️⁵ | ✅ | ❌ | | Data Types | | | | | | | Plain objects & arrays | ✅ | ✅ | ✅ | ✅ | ✅ | | Map & Set support | ✅ | ✅⁶ | ✅ | ✅ | ✅ | | Class instances | ❌ | ❌ | ✅⁷ | ❌ | ❌ | | Date objects | ❌ | ❌ | ❌ | ❌ | ❌ | | Advanced | | | | | | | Curried producer | ❌ | ✅ | ✅ | ❌ | ✅ | | Current snapshot in recipe | ❌ | ✅ | ✅ | ❌ | ❌ | | Custom shallow copy / plugins | ❌ | ❌ | ✅ | ❌ | ❌ | | TypeScript generics | ✅ | ✅ | ✅ | ✅ | ✅ | | Zero runtime dependencies | ✅ | ✅ | ✅ | ✅ | ✅ |
¹ Immer discourages async inside produce; requires createDraft/finishDraft workaround.
² Structura supports standard patches via enableStandardPatches(true).
³ Mutative auto-freeze is disabled by default for performance; opt-in via enableAutoFreeze.
⁴ Structura freezes at compile-time via TypeScript only, not at runtime.
⁵ Mutative detects circular references only when enableAutoFreeze is enabled in development mode.
⁶ Immer requires calling enableMapSet() to enable Map/Set support.
⁷ Mutative supports class instances via custom mark function.
⁸ Immutate uses deep-readonly TypeScript types to enforce immutability at compile-time with zero runtime overhead.
⚖️ License
This project is licensed under the MIT License.
