Diagnosing a common source of race conditions in JS
My thoughts on why expression statements can be a source of bugs—especially in async code—and a proposal for reviving JavaScript's nearly unused void operator.
What’s an expression statement?
1 + 3
is an expression. It’s like writing 4
in JS except you make the
computer figure out the answer for you. console.log(1 + 3);
is a statement.
Arguments to functions are always expressions. And console.log(x);
is a
statement… because it has a semicolon at the end?
Let’s consider that a working definition rather than a technical one. But in JS
you can also write 1 + 3;
as a statement. You can’t write
console.log(1 + 3;);
. Even though assignment is usually done in a statement,
like x = 4;
, the syntax x = 4
is actually an expression. You can write
console.log(x = 4);
which will assign x
to 4
, evaluate to 4
, and then
console.log
that.
Expression statements exist for so we can use expressions that have side effects.
Async functions make things even harder
JS already has confusing things like array.sort()
, which both modifies the
array (a side effect) and returns a value. With async functions not only are
the side effects hard to discover, but the completion of the function is easy
to ignore as well. If you try to use the return value of a function, but it’s
actually a
Promise,
this usually isn’t too bad to figure out. But if you’re “throwing away” the
result via an expression statement, it can be really hard to track down the
stray promise.
someAsyncFunction();
await anotherAsyncFunction();
Imagine if these functions had less obvious names:
load();
await update();
And someone went through and refactored load
to be aysnc when it wasn’t
before. Now your code has a
race condition,
where the order of side effects can differ based on how long the different code
sections take.
What if it was an error to ignore function return values?
If there was a way to make JS complain when we don’t use the return values from functions, we could use the existing void operator to explicitly discard return values we don’t care about:
const myArray = [3, 2, 1];
// LINTER ERROR: return value not used or ignored
myArray.sort();
// OK: return value explicitly discarded
void myArray.sort();
The
no-floating-promises
rule from typescript-eslint actually uses this
exact approach! This sort of behavior would be hard to add to JS without engine
level changes, but a linter plugin for TypeScript can somewhat reliably catch
issues like this. Because void
doesn’t change behavior in statement context,
it’s safe to add to your existing code without changing runtime behavior.