javascripttypescriptasync-awaitoptional-chaining

Optional chaining and await


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?


Solution

  • 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.