IO and Task: Pushing Side Effects to the Edge of the World (Part 11 of the Functional Programming Series)

Part 11 of the Functional Programming in Practice series. This is the part that answers the question you have been holding since Part 1: if functions cannot have side effects, how do you build anything that actually does something?

Hey there, Coding Chefs! 👨‍💻

We need to settle a debt. Way back at the start of this series, I told you a pure function cannot have side effects: no writing to a database, no calling an API, no reading the clock, no logging. And I promised that the obvious objection, "then how do I build a real application that does literally any of those things?", would get a complete answer later. This is later.

Because that objection is correct. Every useful program does side effects. An app that cannot touch the network or a database or the screen is a very fast way to heat up a CPU and nothing else. So functional programming cannot actually forbid side effects. What it does instead is something cleverer: it makes side effects into values you describe and pass around, and only runs them at one controlled spot at the very edge of your program. The structures for this are IO (for synchronous effects) and Task (for asynchronous ones). By the end of this part, side effects stop being a paradox and become an architecture, and the most satisfying trick in all of functional programming, making async and sync code look identical, will be in your hands. Let's get cracking.

The Trick: Describe the Effect, Do Not Perform It

Here is the whole idea in one sentence. A function that performs a side effect is impure. But a function that returns a description of a side effect, without performing it, is pure.

Sit with that, because it is the key to everything. Consider logging:

// impure: it actually logs, right now, reaching outside itself
const incrementAndLog = (n: number): number => {
  const result = n + 1;
  console.log(result); // side effect, happens immediately
  return result;
};

Calling this performs the log. It touches the outside world. Impure.

Now the move. Instead of performing the log, return a little function that would perform the log when someone eventually calls it:

// pure: it returns a description (a function) and performs nothing
const incrementAndLog = (n: number): (() => number) => {
  const result = n + 1;
  return () => {
    console.log(result); // this runs only if and when someone calls the returned function
    return result;
  };
};

Look closely. incrementAndLog now performs no side effect when you call it. It just builds and hands back a function, a recipe, that contains the instruction "log this, then return it." Calling incrementAndLog(5) does nothing observable. The logging only happens later, when something actually invokes the recipe. And because incrementAndLog now returns the same recipe every time for the same input, it is pure again. We made it honest by postponing the effect.

A function that takes no input and returns a value is called a Thunk. A thunk is normally useless for pure code (a no-input pure function just returns a constant). But a thunk is the perfect container for a deferred side effect, precisely because the effect lives inside it, waiting, instead of firing immediately.

IO: A Structure for Deferred Effects

We give this pattern a name and a type. IO of A is a structure that, when you finally run it, produces a value of type A while doing whatever side effects it contains. Under the hood it is just that thunk, dressed up as a named idea.

type IO<A> = () => A;

const incrementAndLog = (n: number): IO<number> => () => {
  const result = n + 1;
  console.log(result);
  return result;
};

const program = incrementAndLog(5); // nothing logged yet. just a description.
// ... much later, at the very edge of the app ...
program(); // NOW it logs 6 and returns 6

What IO buys you is the power to postpone and delay side effects, so the entire body of your application can stay pure. You build up a description of what should happen, compose it freely with all the tools from earlier parts, and only at the final boundary do you "run" it, once, on purpose. This is the architecture famously called functional core, imperative shell: a pure core made of values and descriptions, wrapped in a thin impure shell at the edge that actually executes them.

And here is the connection that should click: IO is a Functor. Say download(url) returns an IO<string> and isJson(s) is a plain function from string to boolean. Their types do not line up directly, the same wall from before. But because IO is a Functor, you map isJson into the IO context and they compose cleanly, producing an IO<boolean>. You build your whole effectful program by composing IO values with map and friends, never running anything until the end. (And just like Either modeled errors as values, an IO that might fail is paired with Either into an IOEither, so even effectful errors stay in the type.)

Task: IO for the Asynchronous World

IO has one limitation we have to address: it is synchronous. It blocks until it produces its value. But most real side effects, fetching from a server, querying a database, are asynchronous. They hand you a Promise.

The instinct is to just use Promises directly. But Promises have problems for pure functional code. The big one: a Promise runs the moment you create it. There is no "describe now, run later," the work fires immediately, which breaks the entire "postpone the effect" discipline we just built. Promises also make it dangerously easy to forget a rejection handler and crash your app with an unhandled rejection, and the language does not protect you.

So we combine IO and Promise into a new structure and call it Task. A Task<A> models an asynchronous computation that, when run, eventually produces an A. Concretely, it is a thunk that returns a Promise: () => Promise<A>.

type Task<A> = () => Promise<A>;

const fetchBooks: Task<Book[]> = () => fetch("/books").then((r) => r.json());
// creating fetchBooks does NOT start the fetch. it is a description.
// fetchBooks() starts it, when you are ready.

Two wins fall out immediately. First, a Task does not run when created, only when you trigger it, restoring the describe-then-run discipline that raw Promises destroy. Second, you can pair Task with Either to get TaskEither, an async computation that can fail, with the error sitting right in the type. No more unhandled rejections crashing the process. Every failure is planned for at the type level.

The Most Satisfying Trick: Sync and Async Look Identical

Here is the payoff that makes this whole apparatus worth it, and it is genuinely beautiful.

Both IO and Task are Functors. Both support the same operations (map, and a lift-a-value operation usually called of). That means code written against them looks the same whether the underlying effect is synchronous or asynchronous.

Take rolling a die. Math.random() is impure (non-deterministic, the very thing we flagged at the start), so we lift it into IO:

const rolledDice: IO<number> = () => Math.floor(Math.random() * 6) + 1;

const isEven = (n: number) => n % 2 === 0;

// check if the roll is even, using map, while staying inside the context:
const rolledIsEven = map(isEven)(rolledDice); // IO<boolean>

Now suppose the random number came from an async service instead. rolledDice becomes a Task<number> instead of an IO<number>. But the line that checks evenness, map(isEven)(rolledDice), does not change at all. Same map, same shape, now producing a Task<boolean> instead of an IO<boolean>. Your synchronous code and your asynchronous code look identical.

Think about what that means. Synchronicity stops being a thing you architect around. You do not rewrite your logic when a dependency goes from sync to async. You do not color half your functions async and thread await everywhere. You write your transformations against the Functor interface, and whether the effect blocks or awaits becomes an irrelevant implementation detail handled by the structure. (The only seam is that IO and Task technically need different map implementations, but a good library wires that up so you rarely think about it.) That is the dream of effect management: write the logic once, swap the execution model freely.

Calling the Debt: Randomness, Clocks, and Purity

Remember the cliffhangers from the very first part? "How do we do randomness if functions must be deterministic? How do we read the clock? How do we do anything useful with no side effects?" Here is the resolution, finally complete.

We never made Math.random() pure. We wrapped it in IO, which turns "perform a random roll right now" into "a description of a random roll, to be performed later." The description is pure (same description every time). The performance happens once, at the edge. Same for Date.now(), for database writes, for API calls, for logging. The effect lives honestly in the type (IO<number>, Task<Book[]>, TaskEither<DbError, User>), it is composed purely through the core of your app, and it is executed at exactly one controlled boundary. The paradox dissolves. You can do every side effect you ever needed, you just describe them as values and run them on purpose.

But Everything Has a Cost

The honest caveats, and there are meaningful ones here.

This is the heaviest abstraction in the series. Wrapping every effect in IO/Task, composing with map/chain, and running at the edge is a real shift in how you structure code, and it has a learning curve. Modern effect systems like Effect unify IO, Task, error handling, and dependency injection into a single Effect type, which is powerful but is also a substantial framework to adopt.

You can over-engineer effect tracking. This is the heretical-but-correct take I want to leave you with. For many teams and many backends, full IO/Task discipline everywhere is more machinery than the problem warrants. The high-return version is simpler than the textbook: keep a genuinely pure core (your business logic, your calculations, your transformations, all pure functions you can test with zero mocks), and an honest impure shell at the edge that does the IO plainly. You get most of the testability and reasoning benefits without wrapping the universe. Reach for full IO/Task or Effect when the complexity of your effects actually justifies it.

The testing payoff is the real prize. Here is why even the lightweight version is worth it: a pure core needs no mocks. Your logic takes values and returns values, so you test it by passing inputs and checking outputs, full stop. The impure version, effects scattered through the logic, is the one that drowns in mocks and spies and brittle setup. Pushing effects to the edge is what makes the inside trivially testable.

The Honest Conclusion

Functional programming never actually banned side effects, that would make it useless. It banned performing them in the middle of your logic. The fix is to describe an effect as a value, an IO for synchronous effects, a Task for asynchronous ones, compose those descriptions purely through the core of your application, and execute them at one controlled spot at the very edge. Functional core, imperative shell.

That single reframe answers every cliffhanger we opened the series with. Randomness, clocks, databases, networks, all of them become values you carry in honest types and run on purpose, instead of hidden impurities scattered through your code. And the prize at the end, sync and async code that looks identical because both are just Functors, means you stop architecting around synchronicity entirely.

Purity is not about never doing side effects. It is about doing them all in one place, on purpose, where you can see them, instead of scattered through logic you wanted to trust.

Next up: The keystone, and the bridge to where my work is heading. Next we look at how functional programming has verified code for decades with property-based testing and invariants, and why those exact techniques are the ancestors of the AI evals that modern teams are reinventing right now.