Is there any reason to use if
to check if method exists, before calling await
if (someObject.save){
await someObject.save();
}
rather than using nullish coallescence diectly with await
await someObject.save?.();
Is second code a safe alternative to the former?
Is second code a safe alternative to the former?
Yes. But maybe not for the reason one might expect.
The two pieces of code are equivalent in terms of the check made:
const obj = {
foo() { console.log("method called"); },
bar: 42
};
if (obj.foo) {
console.log("obj has foo");
obj.foo(); //works
}
console.log("using optional chaining");
obj.foo?.(); //works
if (obj.bar) {
console.log("obj has bar");
try {
obj.bar(); //error
} catch (err) {
console.log("bar cannot be called");
}
}
console.log("using optional chaining");
try {
obj.bar?.() //error
} catch (err) {
console.log("bar cannot be called");
}
.as-console-wrapper {
max-height: 100% !important
}
However, in terms of async semantics, there is a difference: await
will force an async function to pause. While not having await
will run the function until another await
is encountered or the function finishes and a result is produced.This can lead to very subtle bugs. Consider this case when the behaviour overlaps - there is a save()
method, so both functions pause to await
its result:
let x = 1;
async function testIf(someObject) {
console.log("testIf 1:", x); //some external variable
if (someObject.save){
await someObject.save();
}
console.log("testIf 2:", x); //some external variable
}
async function testOptional(someObject) {
console.log("testOptional 1:", x); //some external variable
await someObject.save?.();
console.log("testOptional 2:", x); //some external variable
}
const obj = {
save() { return Promise.resolve(); }
}
testIf(obj);
testOptional(obj);
x = 2; //change external variable
The behaviour is consistent: both of these will stop at the line with await
, which will yield the execution, which then processes the new assignment to x
, so then when both functions resume, they both read the new value of x
. This is expected.
Now, consider what happens if the await
line is not hit in the if
version:
let x = 1;
async function testIf(someObject) {
console.log("testIf 1:", x); //some external variable
if (someObject.save){
await someObject.save();
}
console.log("testIf 2:", x); //some external variable
}
async function testOptional(someObject) {
console.log("testOptional 1:", x); //some external variable
await someObject.save?.();
console.log("testOptional 2:", x); //some external variable
}
const obj = {}
testIf(obj);
testOptional(obj);
x = 2;
The semantics changed. For the if
version the await
line is not processed, thus the execution does not yield until the function finishes, thus it reads the value of x
before the reassignment both times. The version that uses optional chaining preserves its semantics even if save
is not present - it still encounters the await
and still yields.
In summary, having optionally asynchronous operation is the path to madness. I would suggest avoiding it at all costs.
Read more about this:
And yes, the code I showed is also probably bad - you most likely should not rely on external values. Yet, sometimes you have to. And even if you do not rely on them, in the future, somebody consuming your code might. It is best to just avoid Zalgo in the first place and have more predictable code.