What is a mutation?
A mutation happens when we directly change a value that already exists. In JavaScript, objects and arrays can be changed (mutated) by default:
// Examples of mutations
const user = { name: 'Alice' };
user.name = 'Bob'; // Mutating an object property
const numbers = [1, 2, 3];
numbers.push(4); // Mutating an array
numbers[0] = 0; // Mutating an array element
These mutations can create bugs that are hard to find, especially in bigger applications.
Why should we avoid mutations?
Let's look at a simple example:
// Code with mutations
const cart = {
items: [],
total: 0
};
function addProduct(cart, product) {
cart.items.push(product);
cart.total += product.price;
}
// Using it
const myCart = cart;
addProduct(myCart, { id: 1, name: "Laptop", price: 999 });
// myCart is changed directly
console.log(cart === myCart); // true, both variables point to the same object
Problems with mutations:
- Shared references: Different parts of your code can change the same object without others knowing
- Side effects: Changes can affect other functions using the same object
- Hard to debug: You can't track which part of your code changed the object
- Complex testing: Mutations make unit tests harder to write
Solution: Immutable programming
The immutable approach creates a new copy of the object for each change:
// Immutable code
function addProduct(cart, product) {
// Create a new object without changing the original
return {
items: [...cart.items, product],
total: cart.total + product.price
};
}
// Using it
const initialCart = { items: [], total: 0 };
const newCart = addProduct(initialCart, { id: 1, name: "Laptop", price: 999 });
console.log(initialCart); // { items: [], total: 0 }
console.log(newCart); // { items: [{...}], total: 999 }
console.log(initialCart === newCart); // false, they are different objects
Benefits of this approach:
- Predictable: Each function returns a new state without hidden effects
- Change tracking: Each change creates a new object you can track
- Easy testing: Functions are pure and simpler to test
- Better debugging: You can compare states before and after changes
Modern tools for immutability
Immer: Simple writing style
Immer lets you write code that looks like regular JavaScript but produces immutable results:
import produce from 'immer';
const initialCart = {
items: [],
total: 0,
customer: {
name: 'Alice',
preferences: {
notifications: true
}
}
};
// Without Immer (long way)
const updatedCart = {
...initialCart,
items: [...initialCart.items, { id: 1, name: "Laptop", price: 999 }],
total: initialCart.total + 999,
customer: {
...initialCart.customer,
preferences: {
...initialCart.customer.preferences,
notifications: false
}
}
};
// With Immer (simple way)
const updatedCartImmer = produce(initialCart, draft => {
draft.items.push({ id: 1, name: "Laptop", price: 999 });
draft.total += 999;
draft.customer.preferences.notifications = false;
});
Benefits of Immer:
- Familiar syntax: Write code like you normally would
- No new API to learn: Use regular JavaScript objects and arrays
- Fast: Only copies the parts that changed
- Automatic change detection: Tracks changes and creates new references only when needed
- Works well with TypeScript: Keeps all your type information
Immutable.js: Efficient data structures
Immutable.js provides special data structures made for immutability:
import { Map, List } from 'immutable';
// Creating immutable structures
const cartState = Map({
items: List([]),
total: 0
});
// Adding an item
const newCart = cartState
.updateIn(
['items'],
items => items.push(Map({
id: 1,
name: "Laptop",
price: 999
}))
)
.update('total', total => total + 999);
// Immutable.js methods always return new instances
console.log(cartState.getIn(['items']).size); // 0
console.log(newCart.getIn(['items']).size); // 1
// Easy comparison
console.log(cartState.equals(newCart)); // false
// Convert back to regular JavaScript
const cartJS = newCart.toJS();
Benefits of Immutable.js:
- Fast with immutable data structures
- Rich API for working with data
- Memory-efficient data sharing
- Easy equality checks with
equals()
- Protection from accidental changes
ESLint configuration for immutability
ESLint can help enforce immutable coding practices through specific rules:
// .eslintrc.js
module.exports = {
plugins: ['functional'],
rules: {
'functional/immutable-data': 'error',
'functional/no-let': 'error',
'functional/prefer-readonly-type': 'error'
}
};
These rules will:
- Prevent direct data mutations
- Encourage using
const
overlet
- Suggest using readonly types in TypeScript
TypeScript and immutability
TypeScript helps enforce immutability through its type system:
// Immutable types for a cart
type Product = {
readonly id: number;
readonly name: string;
readonly price: number;
};
type Cart = {
readonly items: ReadonlyArray<Product>;
readonly total: number;
};
// TypeScript prevents mutations
const cart: Cart = {
items: [],
total: 0
};
// Compilation error: items is read-only
cart.items.push({ id: 1, name: "Laptop", price: 999 });
// Function must create a new cart
function addProduct(cart: Cart, product: Product): Cart {
return {
items: [...cart.items, product],
total: cart.total + product.price
};
}
// TypeScript ensures the original object isn't changed
const newCart = addProduct(cart, { id: 1, name: "Laptop", price: 999 });
TypeScript's readonly modifiers:
-
readonly
: Prevents property changes -
ReadonlyArray<T>
: Prevents array changes -
Readonly<T>
: Makes all properties read-only
These types are checked when you compile, helping catch mistakes early.
Conclusion
Immutability makes your code more predictable and easier to maintain. While it takes some getting used to, the benefits in reliability and maintainability are worth it.
Top comments (0)