If you look at the source code of a typical application, you will likely find business logic tangled with database calls, HTTP requests firing off in the middle of validation rules, and try/catch blocks sprinkled here and there.
The biggest casualty of this coupling is testability. How do you test a function that calculates a user’s discount? That’s easy, right?
But how do you test a function that calculates a discount, looks up the user in a database to check their loyalty status, and then emails them a coupon? You can’t just run the function. You have to spin up a test database, or utilize a mocking library to intercept calls to db.findUser. Your unit tests stop testing your logic and start testing your mocks. Soon, you find yourself spending more time configuring the test environment than writing the code itself.
Fortunately, there is another way. It may feel strange at first, but the idea is deceptively simple:
Don’t do the work right away. Describe the work first.
Think about the difference between cooking a meal and writing a recipe.
- Imperative: You execute the steps one by one: Go to the kitchen. Chop the onions. Stop if you cry. Turn on the stove.
- Declarative: You write down the recipe instead: “Step 1: Chop onions. Step 2: Saute.” You can hand the recipe to someone else. You can also analyze it to see if it contains allergens (bugs) without cooking it.
By the end of this article, we will have a tiny but functional “Effect System” in JavaScript. We will stop writing functions that execute side effects and start writing functions that return descriptions of them instead.
But first, let’s look at a typical example of imperative code that executes the following user registration steps:
- Validate the input.
- Check the database to see if the email exists.
- If it’s new, hash the password and save the user.
// ...
async function registerUser(input) {
try {
const { email, password } = input;
if (!email?.includes('@')) {
throw new Error('Invalid email format.');
}
if (password?.length < 8) {
throw new Error('Password too short.');
}
const foundUser = await db.findUserByEmail(email);
if (foundUser) {
throw new Error('Email already in use.');
}
const userToSave = { email, password: hashPassword(password) };
const savedUser = await db.saveUser(userToSave);
console.log('User Created:', savedUser);
return savedUser;
} catch (error) {
console.error('Registration error:', error);
return { error: error.message };
}
}
The code above is perfectly readable, but it has a number of hidden costs. You can’t test the logic without mocking the database calls. And if you don’t mock, you can’t call registerUser with the same input twice. If you do, it may fail the second time. Finally, logic jumps around via throw/catch, which introduces invisible control flow. Because any function could potentially throw, you can never look at a block of code and be 100% sure that the next line will actually execute.
The Effect System
Instead of doing the work right away, the Effect System returns an object describing the work, which will be evaluated later.
We start by defining three basic objects to represent the state of our program:
- Success: The previous step worked, and here is the result (
value). - Failure: Something went wrong (
error). - Command: We store the async side effect in
cmdas a function, but we don’t run it yet. IfcmdreturnsSuccess, the function innextwill be run afterwards.
const Success = (value) => ({ type: 'Success', value });
const Failure = (error) => ({ type: 'Failure', error });
const Command = (cmd, next) => ({ type: 'Command', cmd, next });
Next, we need a combinator function called chain that stitches these effects together. Its job is to take an existing effect and connect it to the next function (fn) in the pipeline.
const chain = (effect, fn) => {
switch (effect.type) {
case 'Success':
// Pass the success value to function fn
return fn(effect.value);
case 'Failure':
// Short-circuit on error
return effect;
case 'Command':
// Create a new command that chains the command's result to fn recursively
const next = (result) => chain(effect.next(result), fn);
return Command(effect.cmd, next);
}
};
We will also need a simple effectPipe helper. This accepts a list of functions and runs them one by one, passing the output of the previous function into the next, but only if the previous one succeeded (thanks to chain).
const effectPipe = (...fns) => {
return (start) => fns.reduce(chain, Success(start));
};
To wrap it up, we need one last function: the interpreter. So far, we haven’t run anything; we have just built a big object describing what we want to do. Our runEffect function is the only place where async/await lives. It loops through our commands and executes them one by one:
async function runEffect(effect) {
while (effect.type === 'Command') {
try {
effect = effect.next(await effect.cmd());
} catch (e) {
return Failure(e);
}
}
return effect;
}
And that’s it! If you don’t quite understand how these all work, it’s ok. Let’s refactor the original registerUser function using our Effect system, and see how the resulting code will look like.
From Imperative to Declarative Code
We first put the validation logic in its own function and return Success or Failure objects instead of throwing exceptions:
function validateRegistration(input) {
const { email, password } = input;
if (!email?.includes('@')) {
return Failure('Invalid email format.');
}
if (password?.length < 8) {
return Failure('Password must be at least 8 characters long.');
}
return Success(input);
}
Next, we handle the database check. We wrap the db.findUserByEmail execution in a function and pass it as the cmd. In the next argument, we define a function that will receive the foundUser (in the future) and wrap it in a Success object. Note that nothing is run here. We are simply describing the command and what will happen next once it is run.
function findUserByEmail(email) {
const cmdFindUser = () => db.findUserByEmail(email);
const next = (foundUser) => Success(foundUser);
return Command(cmdFindUser, next);
}
We put the email availability check in its own function:
function ensureEmailIsAvailable(foundUser) {
return foundUser ? Failure('Email already in use.') : Success(true);
}
Finally, the save operation. After we hash the password, just like the read operation earlier, we wrap the db.saveUser execution in a function and pass it as the cmd. In the next argument, we define a function that will receive the savedUser and wrap it in a Success object. Once again nothing is run here.
function saveUser(input) {
const { email, password } = input;
const userToSave = { email, password: hashPassword(password) };
const cmdSaveUser = () => db.saveUser(userToSave);
const next = (savedUser) => Success(savedUser);
return Command(cmdSaveUser, next);
}
Now, we use effectPipe to stitch these steps together into a single “recipe”, so to speak. In the pipeline below, we use arrow functions () => ... to capture the original input variable. This ensures we can access the initial data even if the previous step returned a different result (like true).
const registerUserFlow = (input) =>
effectPipe(
validateRegistration,
() => findUserByEmail(input.email),
ensureEmailIsAvailable,
() => saveUser(input)
)(input);
Finally, we run our program:
async function registerUser(input) {
return await runEffect(registerUserFlow(input));
}
OK, let’s slow down here a bit and reflect on what we have done so far. We haven’t just moved things around the code. We have fundamentally changed its architecture. By decoupling the description of the workflow from its execution, we gain two distinct advantages that are often difficult to achieve with standard async/await.
Testing without Mocks
In our new version, registerUserFlow is a pure function. It doesn’t touch the database; it returns a data structure representing the database interaction. This means we can test our business logic without spinning up a test database or using complex mocking libraries.
Testing a failure case is as simple as checking the Failure condition:
const input = { email: 'bad-email', password: '123' };
const effect = await registerUser(input);
assert.deepEqual(effect, Failure('Invalid email format.'));
We can even inspect the intent of the code. We can verify that the code intends to look up a user, without actually doing it.
const input = { email: '[email protected]', password: 'password123' };
const step1 = registerUserFlow(input);
assert.equal(step1.type, 'Command');
assert.equal(step1.cmd.name, 'cmdFindUser');
const step2 = step1.next(null);
assert.equal(step2.type, 'Command');
assert.equal(step2.cmd.name, 'cmdSaveUser');
Free Logging & Profiling
Because every database call and API request must flow through the runEffect interpreter, we can handle cross-cutting concerns like logging, performance profiling, and error reporting in exactly one place.
Instead of polluting the business logic with scattered console.log statements, we can simply plug these features into the engine. We update the interpreter once, and suddenly the entire application gets smarter.
Here’s a quick example of how to log every async operation and measure how long each operation takes:
async function runEffect(effect) {
console.log('--- Starting Pipeline ---');
while (effect.type === 'Command') {
const start = performance.now();
try {
const result = await effect.cmd();
const duration = (performance.now() - start).toFixed(1);
console.log(`✅ [${effect.cmd.name}] completed in ${duration} ms`);
effect = effect.next(result);
} catch (e) {
console.error(`❌ [${effect.cmd.name}] failed:`, e);
return Failure(e);
}
}
console.log('--- Pipeline Finished ---');
return effect;
}
Now, when we run our unchanged registerUser code, we get this output:
--- Starting Pipeline ---
✅ [cmdFindUser] completed in 12.2 ms
✅ [cmdSaveUser] completed in 32.5 ms
--- Pipeline Finished ---
The M Word
Those who have a background in functional programming languages like Haskell will notice that we have created not one, but two Monads in just 30 lines of code:
- The Either Monad (Error Handling): By distinguishing between Success and Failure, our pipeline automatically manages the “Sad Path.”
- The Free Monad (Side Effects): By representing our side effects as data objects (
Command) rather than opaque functions, we built a transparent syntax tree of our program. This is what allows us to inspect, test, and interpret our code without actually running it.
Monad is a functional programming pattern, and is often treated as a scary word, associated with complex math. But at its core, a Monad is a method to put a value in a container along with some metadata to handle complexity.
The Functional Core and the Imperative Shell
What we have built here is essentially a micro-implementation of a classic architectural pattern known as Functional Core, Imperative Shell.
The goal of this architecture is to push all side effects to the boundaries of our application, leaving the center pure and predictable.
- The Functional Core: In our example,
registerUserFlowrepresents the core. It contains all our important business rules, validation logic, and decision trees. It is 100% pure, has no dependencies, and is trivial to test because it deals only with data. - The Imperative Shell: The
runEffectfunction acts as the engine of the shell. Along with our database adapters and entry points, it handles the chaotic reality of the outside world.
By strictly separating these two worlds, we minimize the surface area for bugs. The logic that defines what the application does is safe inside the core, while the logic that defines how to execute it is isolated in the shell.
GitHub Repository: pure-effect
Related: