I started my programming career with a lot of Java. It is a great runtime but always felt too verbose for my liking and required lots of discipline to not overcomplicate code with unnecessary hierarchy relationships. Looking for alternatives I stumbled upon a lot of JVM languages like: Scala, Kotlin and eventually Clojure.

Although I never liked the lisp syntax I appreciated its expressiveness. Eventually I got to find the many great talks from the author of Clojure, Rich Hickey. Today I would like to share how we can apply his Simple Made Easy ideas to JavaScript.

Object and Methods vs Values and Functions

With the increasing popularity of React this functional programming paradigm has already been pushed pretty hard. Approaches are similar but have subtle differences, in objects you are tying the data and the behaviour. As you start adding more behaviours you will end up with situations in which you want to reuse some behaviour and you are forced to create a complex object only to access a small part of it. As the author of Erlang, Joe Armstrong, said "You wanted a banana but what you got was a gorilla holding the banana and the entire jungle."

Imagine a simple online shopping cart implementation:

class ShoppingCart {
  constructor() {
    this.items = []; 
    this.taxRate = 0.08;
  }

  addItem(item) {
    this.items.push(item);
  }

  calculateTotal() {
    let subtotal = 0;
    for (const item of this.items) {
      subtotal += item.price;
    }
    const tax = subtotal * this.taxRate;
    return subtotal + tax;
  }
}

const cart = new ShoppingCart();
cart.addItem({ name: "Banana", price: 1 });
cart.addItem({ name: "Apple", price: 2 });
cart.calculateTotal(); // Returns 3.24

Everything is nice and simple but what happens when in our shopping site we decide to add a wishlist where users can also store items and calculate their price. Should we create a new class which holds the calculate total method and have both the ShoppingCart and the Wishlist classes inherit from it?
Instead we can just focus on the data and the behaviour separately:

const cart = {
  items: [
    { name: "Banana", price: 1 },
    { name: "Apple", price: 2 }
  ],
  taxRate: 0.08
};

const wishlist = [
  { name: "Book", price: 15 },
  { name: "Headphones", price: 100 }
];

const Cart = {
  calculateTotal: (items, taxRate = 0.08) => {
    const subtotal = items.reduce((sum, item) => sum + item.price, 0);
    const tax = subtotal * taxRate;
    return subtotal + tax;
  },
};

const cartTotal = Cart.calculateTotal(cart.items, cart.taxRate);  // Returns 3.24
const wishlistTotal = Cart.calculateTotal(wishlist); 

Our mind has a limit on how many things we can keep track of at the same time. By isolating behaviour and making clear what data is involved we can reduce the cognitive load and make our code easier to understand.

Switch vs Polymorphism

One of the great things of OOP is polymorphism. It allows you to decouple different implementations and avoid switch statements which eventually don't scale.

class ShoppingCart {

  /* ... */

  calculateTotal() {
    let subtotal = 0;
    let tax = 0;

    for (const item of this.items) {
      switch (item.type) {
        case 'physical':
          // Apply tax
          subtotal += item.price;
          tax += item.price * this.taxRate; 
          break;
        case 'service':
          // No tax
          subtotal += item.price;
          break;
      }
    }
    return subtotal + tax;
  }
}

When you are using classes you can create a Taxable interface and have different implementations for each item type:

class PhysicalItem {
  /* ... */
  calculatePrice(taxRate) {
    return this.price + (this.price * taxRate); // Apply tax
  }
} 
class DigitalItem {
  /* ... */
  calculatePrice() {
    return this.price; // No tax
  }
}

class ShoppingCart {
  /* ... */
  calculateTotal() {
    let total = 0;
    for (const item of this.items) {
      total += item.calculatePrice(this.taxRate);
    }
    return total;
  }
}

const cart = new ShoppingCart();
cart.addItem(new PhysicalItem("Laptop", 1000));
cart.addItem(new DigitalItem("E-book", 50));
cart.calculateTotal(); // Returns 1130

But we can easily achieve the same result without the need for classes:

const cart = {
  items: [
    { type: "physical", name: "Laptop", price: 1000 },
    { type: "digital", name: "E-book", price: 50 }
  ],
  taxRate: 0.08
};

const calculatePrice = {
  physical: (price, taxRate) => price + (price * taxRate), // Apply tax
  digital: (price) => price, // No tax
};

const Cart = {
  calculateTotal: (items, taxRate = 0.08) => {
    return items.reduce(
      (sum, item) => sum + calculatePrice[item.type](item.price, taxRate),
      0);
  },
};

const cartTotal = Cart.calculateTotal(cart.items, cart.taxRate);  // Returns 1130

Controlled vs Uncontrolled State

We've all been told that global state is bad and it is indeed true that it makes things harder to understand. The reality is that we add global state all the time in databases, caches, sessions... it is necessary.

const userSession = {
  isLoggedIn: false,
  cart: [],
  theme: 'light'
};

// --- 'auth.js' ---
function handleLogin() {
  /* ... */
  userSession.isLoggedIn = true; // Direct mutation
  userSession.theme = 'dark';    // Direct mutation
}

// --- 'cart.js' ---
function addItem(item) {
  /* ... */
  userSession.cart.push(item); // Direct mutation
}

// --- 'settings.js' ---
function changeTheme(newTheme) {
  /* ... */
  userSession.theme = newTheme; // Direct mutation
}
    

It is all well and good until you have to debug an issue in the state, then you have to add a breakpoint in every state modification, which requires you to find them in the first place. Instead we can create a central place where all the state modifications happen.

const userSession = (() => {
  let session = {
      isLoggedIn: false,
      cart: [],
      theme: 'light'
  };

 return {
   getSession: () => structuredClone(session),
   update: (updater) => {
     updater(session);
     console.log('Session updated:', session);
   }
 }
})();

// --- 'auth.js' ---
function handleLogin() {
  userSession.update((session) => {
    session.isLoggedIn = true;
    session.theme = 'dark';
  });
}

// --- 'cart.js' ---
function addItem(item) {
  userSession.update((session) => {
    session.cart.push(item);
  });
}
    

The global state still adds complexity but now when there is a problem you have an easy way to track all the changes and find the root cause.

Iterative loops vs Collection pipelines

Similar to switch statements, iterative loops can get out of hand pretty quickly. As you start adding more and more logic you are reusing the loop which is great for performance but forces us to keep more things in mind.

const numbers = [1, 2, 3, 4, 5];
const squaresOfEvens = [];
for (let i = 0; i < numbers.length; i++) {
  const num = numbers[i];
  if (num % 2 === 0) {
    squaresOfEvens.push(num * num);
  }
}
console.log(squaresOfEvens); // [4, 16]

Instead we can use collection pipelines to separate each step of the process:

const numbers = [1, 2, 3, 4, 5];
const squaresOfEvens = numbers
  .filter(num => num % 2 === 0)
  .map(num => num * num);
console.log(squaresOfEvens); // [4, 16]

Conditionals vs Rules

Similarly to switch statements and loops, conditionals can get out of hand pretty quickly. If the logic gets complex you start getting nested structures which are tied together and difficult to reason about.

function addProjectCollaborator(project, collaborator, user, subscription) {
  if (project.collaborators.includes(user)) {
    if (project.status === "active") {
      if (project.collaborators.length < subscription.limits.numberOfProjectCollaborators) {
        project.collaborators.push(collaborator);
      } else if (subscription.status === "paid" || subscription.status === "trialing" ) {
         throw new Error("Reached the subscription limit, please upgrade");
      } else if (subscription.status === "inactive") {
         throw new Error("Only available in paid plans, please subscribe");
      }
    } else {
       throw new Error("Project isn't active");
    }
  } else {
    throw new Error("Not enough permissions");
  }
}

Try to compose the logic with independent rules:

function checkRules(context, rules) {
  for (const { check, error } of rules) {
    if (!check(context)) {
      throw new Error(error);
    }
  }
}
function hasPermission({ project, user }) {  
  return project.collaborators.includes(user);
}
function isProjectActive({ project }) {
  return project.status === "active";
}
function isWithinSubscriptionLimits({ project, subscription }) {
  return project.collaborators.length < subscription.limits.numberOfProjectCollaborators;
}
function isSubscriptionValid({ subscription }) {
  return subscription.status === "paid" || subscription.status === "trialing";
}
checkRules({ project, user, subscription }, [
  { check: hasPermission, error: "Not enough permissions" },
  { check: isProjectActive, error: "Project isn't active" },
  { check: isWithinSubscriptionLimits, error: "Reached the subscription limit, please upgrade" },
  { check: isSubscriptionValid, error: "Only available in paid plans, please subscribe" },
]);
addProjectCollaborator(project, collaborator);

Syntax vs Data

This might be the hardest one to grasp. If you've never played with Lisp where code are just nested lists or MongoDB queries which are just JSON. In JavaScript the most familiar approach is to use syntax instead of data all the time. Good thing about data is that it is much easier to manipulate and use.

Imagine a simple api using the Express framework:

const express = require("express");
const app = express();

const userRoutes = express.Router();
userRoutes.post("/login", loginHandler);
userRoutes.post("/logout", logoutHandler);
app.use("/users", userRoutes);

const productRoutes = express.Router();
productRoutes.post("/products", createProductHandler);
productRoutes.get("/products/:id", getProductHandler);
app.use("/products", productRoutes);

Everything is fine until you want to change to another framework, now all your route definitions are tied to Express syntax. Instead we can define our routes as data:

const routes = [
  { method: "POST", path: "/users/login", handler: loginHandler },
  { method: "POST", path: "/users/logout", handler: logoutHandler },
  { method: "POST", path: "/products", handler: createProductHandler },
  { method: "GET", path: "/products/:id", handler: getProductHandler },
];

// Express setup
const express = require("express");
const app = express();
routes.forEach(({ method, path, handler }) => {
  app[method.toLowerCase()](path, handler);
});

// Fastify setup
const fastify = require("fastify")();
routes.forEach(({ method, path, handler }) => {
  fastify.route({ method, url: path, handler });
});

Unfortunately we would still need to adapt each handler because each frameworks models the exact same query, body or response data in a different way. That is the core problem we face, we are constantly modelling data with syntax in slightly different ways causing endless work on migrations. So if you have the choice always try to model with data first.

ORM vs SQL

Tied to the last point we talked about, ORMs are a great example of syntax over data. They try to abstract away the database queries into objects and methods which end up being leaky abstractions.

// ORM, eg: Sequelize
import { Sequelize, DataTypes } from 'sequelize';
const sequelize = new Sequelize('sqlite::memory:');

const User = sequelize.define('User', {
  id: DataTypes.INTEGER,
  age: DataTypes.INTEGER,
  name: DataTypes.STRING,
});

const jane = await User.create({
  name: 'Jane',
  age: 25,
});
const users = await User.findAll({ where: {
  age: { [Sequelize.Op.gt]: 18
} } });

I understand the appeal of ORMs, they provide a familiar interface, handle types, makes queries easier to write but they are tying up the data to the persistence logic. We can have the same benefits if we use a query builder:

// Query Builder, eg: Knex
import knex from 'knex'({
  client: 'sqlite3',
  connection: {
    filename: ":memory:",
  }
});

/**
 * @typedef {Object} User
 * @property {number} id
 * @property {number} age
 * @property {string} name
 *
 * @returns {Knex.QueryBuilder}
 */
const Users = () => knex('Users');

const jane = await Users().insert({
  name: 'Jane',
  age: 25,
});
const users = await Users().where('age', '>', 18);

Now you can easily use your user type without it being tied to the database logic. Or you can create behaviours which use multiple database tables or parts of tables without having to overcomplicate the ORM models. Much easier to compose and move around behaviour.

Concurrency vs Sequential

Having multiple executions at the same time is hard to reason about. One of the reasons to love JavaScript in the first place is its single threaded nature. Still this doesn't mean that each execution path is executed in order, the execution will jump between parts of the code as they reach a point in which they have to wait. Even using JavaScript you may have WebWorkers or multiple Node.js processes using the same database we can't escape that fact.

async function transferFunds(fromAccount, toAccount, amount) {
  const fromBalance = await getAccountBalance(fromAccount);
  if (fromBalance < amount) {
    throw new Error('Insufficient funds');
  }
  await deductFromAccount(fromAccount, amount);
  await addToAccount(toAccount, amount);
}

In this classical example, a race condition can happen. While we are waiting for the balance to be fetched another transfer could modify the balance which could cause an invalid negative balance. A queue is a perfect tool to transform it into a sequence and have only one process make the updates:


accountQueue.add(transferFunds, { fromAccount, toAccount, amount });
while (accountQueue.hasPendingTasks()) {
  await accountQueue.processNextTask();
}
  

Now we can be sure that each transferFunds execution will happen one at a time and we won't have race conditions. The trade off is performance, but in most cases the simplicity is worth it.

Conclusion

Simple doesn't mean familiar. That is the true lesson here, our experience leaves a heavy bias on our decision making. Simple means untangled. Our brains are pretty limited in the amount of things we can keep track of at the same time, so we need to try and reduce the cognitive load as much as possible. Achieving simple code isn't easy but the benefits are worth it in the long run.