Clean Code: JavaScript immutability, core concepts and tools

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 over let Suggest using readonly types in TypeScript TypeScript and immutability TypeScript helps enforce immutability through its type system: // Immu

Jan 17, 2025 - 12:45
Clean Code: JavaScript immutability, core concepts and tools

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:

  1. Shared references: Different parts of your code can change the same object without others knowing
  2. Side effects: Changes can affect other functions using the same object
  3. Hard to debug: You can't track which part of your code changed the object
  4. 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:

  1. Predictable: Each function returns a new state without hidden effects
  2. Change tracking: Each change creates a new object you can track
  3. Easy testing: Functions are pure and simpler to test
  4. 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 over let
  • 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: Prevents array changes
  • Readonly: 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.