It only takes a single asynchronous function, buried somewhere in your call tree, for the contamination to ripple all the way to the top. The function that calls it has to become asynchronous, then the one calling that one, and so on, cascading until half your codebase has changed signature. This phenomenon has a name: Function Coloring.
Bob Nystrom popularized the term in 2015 in a now-famous post, What Color is Your Function?, but the problem itself had been lived through long before it was named. It’s one of those frictions you end up taking for granted, until the day you realize it’s anything but inevitable.
The idea: functions that have a color
The idea is this: in most runtimes, you can think of functions as carrying a “color” depending on their nature.
- 🟦 Synchronous: the function returns its result immediately.
- 🟥 Asynchronous: the function returns a promise of a result: a
Promise, a callback, aFuture, depending on the language and the API.
This distinction would be trivial if the two colors coexisted freely. The problem is that they don’t. An asynchronous function can only be handled properly from another asynchronous function, or you lose control over the program’s execution: you can’t await an asynchronous result from a synchronous context without blocking the runtime or throwing away the return value.
In other words, calling an asynchronous function from a synchronous one comes at a price: the calling function has to become asynchronous too.
The cascading contamination
Take the most mundane synchronous function imaginable, one that reads a file and returns its contents:
function readFile(): string {
const buffer = fs.readFileSync("package.json");
return buffer.toString();
}
Everything is fine as long as we stay in the synchronous world. But suppose we now want to switch to the file system’s asynchronous API, a perfectly legitimate choice, if only to avoid blocking the event loop. The signature of readFile inevitably changes:
async function readFile(): Promise<string> {
const buffer = await fs.readFile("package.json");
return buffer.toString();
}
The body of the function has barely moved. Yet its color has changed: readFile went from 🟦 to 🟥. And with it, all of its callers have to follow. Every function that used readFile must now await it, therefore become async, therefore force its own callers to do the same, step by step all the way up to the root.
A simple change of implementation (making an operation asynchronous) thus triggers a cascading rewrite of all the calling code. The technical decision stays local, but its cost propagates across the entire call tree.
The real problem: a permanent architectural boundary
The cost of the cascade is only the visible part. What weighs on you over time is the boundary that rises permanently between the two worlds, and the cognitive load it imposes.
The two colors don’t have the same APIs, the same type signatures, or the same error-handling mechanisms. async/await did a lot to narrow the gap by making asynchronous code as readable as synchronous code, but it doesn’t remove the boundary: it merely makes it more comfortable to cross. The color stays baked into the signature, and keeps dictating who can call whom.
The main drawback, to my mind, plays out at the level of functional composition. As soon as you try to assemble functions together, the color reappears as a constraint: you don’t compose a synchronous function and an asynchronous one the same way, and every junction point between the two worlds adds friction. What should be an implementation detail becomes a first-order architectural concern.
What if sync and async became an implementation detail again?
That’s exactly the question Effect Systems answer. In this paradigm, you no longer reason in terms of synchronous or asynchronous operations, but in terms of side effects. You describe what the program does; how the operation is executed, synchronously or asynchronously, becomes the system’s responsibility, no longer yours. Reconciling the two worlds is delegated to a dedicated runtime.
In TypeScript, Effect embodies this approach. The boundary between colors fades behind a single datatype, Effect<A, E, R>, which describes a computation through three parameters:
A: the value produced by the operation on success.E: the possible errors, explicitly typed and visible in the signature.R: the dependencies (the context) required for execution.
The key point: whether the operation is synchronous or asynchronous under the hood, the composition stays rigorously identical. You declare a file-reading operation without saying a word about its color:
declare function readFile(fileName: string): Effect<string, ReadFileError, FileSystem>;
Then you compose it like any other Effect, chaining steps and handling errors declaratively:
const program = pipe(
readFile("package.json"),
Effect.catchTag("ReadFileError", () => Effect.logError("oops"))
);
This program 🟪 doesn’t change by a single line depending on whether readFile is synchronous 🟦 or asynchronous 🟥 in its implementation. It’s the Effect runtime that decides how to execute the computation: the color has vanished from the surface of the code. You describe what your program does, its possible errors and its dependencies; the runtime takes care of the rest.
The diagram below sums up this shift: on the left, the world without Effect, where adding a single asynchronous function forces the whole call tree to cascade; on the right, the unified world of Effect, where the same composition holds everywhere, the runtime alone deciding between sync and async.
A paradigm shift, not a syntax trick
It would be reductive to see this as mere syntactic sugar. Effect Systems are a direct inheritance from functional programming: you find them in Haskell, or with ZIO in Scala, which Effect openly draws inspiration from. Their promise goes well beyond the color of functions: errors typed in the signature, explicit dependencies, execution controlled by the runtime.
But on the specific question of Function Coloring, the verdict is unequivocal: the problem no longer arises, not because we worked around it, but because there’s no longer any color to reconcile. Sync or async goes back to being what it should always have been: an implementation detail.