Why I Return Data and Error, Not Throw

Returning a data and error pair from async functions keeps failures from vanishing. Here is why a boring, enforced pattern beats clever error handling.

By Joshua Hayes October 14, 2026

In the codebases I lead, async functions that can fail return an object with two fields: the data, and the error. The caller checks the error before it uses the data.

It is a boring pattern, and the boredom is the point.

What it prevents

Two failures show up again and again in async code. One is a value that is quietly undefined because something upstream failed. The other is an async function that was never awaited, so its failure disappears.

A function that always returns the same shape gives the caller one place to look. It cannot hand back a surprise.

Why not just throw

Throwing is fine in the right place. The trouble is that an uncaught throw in an async path can vanish, or surface far from where it began.

With a returned error, the failure travels with the value. The caller either handles it or visibly ignores it, and a reviewer can see which.

The pattern only works if it is enforced

A convention that people follow most of the time gives you a codebase with two styles. The consistency has to be checked by something other than memory.

I back it with real tooling: typed return shapes, lint rules, and review. If you enforce the boring pattern and follow it up with tools that check it, it adds up to overall stability.

What it costs

Every call site has a small amount of extra code to check the error. Some people find that noisy.

I find the noise useful. It makes the failure path as visible as the success path, which is where bugs like to hide.

Where I use it

I use it for functions that do input and output or anything async, where failure is a normal outcome. Small pure helpers that cannot fail do not need it.

Related

I hold AI-generated code to the same standard. See Why I Write Tests Before AI Writes Code. The custom web applications service covers how I build.