DEV Community

Andrew Nosenko
Andrew Nosenko

Posted on • Edited on

Syntactic sugar: yet another async/await question for JavaScript interviews

Whenever I stumble upon "[something] is a syntactic sugar", I appreciate when it is accompanied by a good technical explanation of what exactly that particular "sugar" is translated to behind the scene. Which isn't always the case.

For instance, try googling "async await syntactic sugar". I don't think the statements like "async is a syntactic sugar for promises" are very helpful in grokking async/await. In my opinion, the concept of the finite-state machine would be very important in this context, yet I couldn't spot the phrase "state machine" in the top Google-quoted results.

So, here is one question I personally find interesting and relevant for the both sides of a JavaScript/TypeScript interview (as well as C#, F#, Python, Dart or any other programming language which has adopted the async/await syntax):

  • How would you go about implementing the following async function <funcName> as a simple state machine, without using the keywords async, await or yield?

I think it's a one-shot-many-kills kind of question, potentially covering the knowledge of the basic topics like promises, closures, exception handling, recursion, in addition to async/await and the state machine concepts themselves.

For a practical JavaScript example, let's take the following simple asynchronous workflow function, loopWithDelay. It runs a loop doing something useful (doWhat), with a certain minimal interval between iterations, until the stopWhen callback signals the end of the loop:

async function loopWithDelay({ doWhat, stopWhen, minInterval }) {
  while (!stopWhen()) {
    const interval = startInterval(minInterval);
    await doWhat();
    const ms = await interval();
    console.log(`resumed after ${ms}ms...`);
  }
  console.log("finished.");
}
Enter fullscreen mode Exit fullscreen mode

We might be calling loopWithDelay like below (runkit). In JavaScript, anything can be awaited, so this works regardless of whether or not doWhat returns a promise:

await loopWithDelay({
  doWhat: doSomethingForMs(150), 
  stopWhen: stopAfterMs(2000), 
  minInterval: 500
});

// a higher-order helper to simulate an asynchronous task
// (for doWhat)
function doSomethingForMs(ms) {
  let count = 0;
  return async () => {
    const elapsed = startTimeLapse();
    await delay(ms); // simulate an asynchronous task 
    console.log(`done something for the ${
      ++count} time, it took ${elapsed()}ms`);
  }
}

// a higher-order helper to tell when to stop
function stopAfterMs(ms) {
  const elapsed = startTimeLapse();
  return () => elapsed() >= ms; 
}

// a simple delay helper (in every single codebase :)
function delay(ms) { 
  return new Promise(r => setTimeout(r, ms)); }

// a higher-order helper to calculate a timelapse
function startTimeLapse() {
  const startTime = Date.now();
  return () => Date.now() - startTime;
} 

// a higher-order helper for a minimal interval delay
function startInterval(ms) {
  const sinceStarted = startTimeLapse();
  return () => {
    const sinceDelayed = startTimeLapse();
    return delay(Math.max(ms - sinceStarted(), 0))
      .then(sinceDelayed);
  };
} 
Enter fullscreen mode Exit fullscreen mode

Of course, there are many ways to rewrite this loopWithDelay without using async/await. We don't have to stricly follow a typical state machine implementation as done by programming language compilers (which can be a bit intimidating, e.g., look at what TypeScript generates when it targets ES5. Interestingly, when targeting ES2015, TypeScript transpiles async/await using generators as an optimization).

To implement loopWithDelay manually as a state machine, we need to break down the normal flow control statements (in our case, the while loop) into individual states. These states will be transitioning to one another at the points of await. Here's one take at that, loopWithDelayNonAsync (runkit):

function loopWithDelayNonAsync({ doWhat, stopWhen, minInterval }) {
  return new Promise((resolveWorkflow, rejectWorkflow) => {
    let interval;

    // a helper to transition to the next state, 
    // when a pending promise from 
    // the previous state is fulfilled
    const transition = ({ pending, next }) => {
      // we fail the whole workflow when 
      // the pending promise is rejected or
      // when next() throws 
      pending.then(next).catch(rejectWorkflow);
    }

    // start with step1
    step1();

    // step1 will transition to step2 after completing a doWhat task
    function step1() {
      if (!stopWhen()) {
        // start the interval timing here
        interval = startInterval(minInterval);
        // doWhat may or may not return a promise, 
        // thus we wrap its result with a promise
        const pending = Promise.resolve(doWhat());
        transition({ pending, next: step2 }); 
      }
      else {
        // finish the whole workflow 
        console.log("finished.");
        resolveWorkflow();
      }
    }

    // step2 will transition to step3 after completing a delay
    function step2() {
      transition({ pending: interval(), next: step3 }); 
    }

    // step3 will transition to step1 after showing the time lapse
    function step3(prevStepResults) {
      // prevStepResults is what the pending promise 
      // from step2 has been resolved to
      console.log(`resumed after ${prevStepResults}ms...`);
      step1();
    }
  });
}

await loopWithDelayNonAsync({
  doWhat: doSomethingForMs(150), 
  stopWhen: stopAfterMs(2000), 
  minInterval: 500
});
Enter fullscreen mode Exit fullscreen mode

Equipped with async/await, we should never have to write code like loopWithDelayNonAsync in real life. It still might be a useful exercise though, especially for folks who first got into JavaScript after it had received the native support for async functions.

Rather than taking async/await syntactic sugar for granted, I think it helps to understand how it works behind the scene as a state machine. It also amplifies how versatile, concise and readable the async/await syntax is.

For a deep dive into async/await under the hood in JavaScript, the V8 blog has an awesome article: "Faster async functions and promises".

Top comments (2)

Collapse
 
mfulton26 profile image
Mark Fulton • Edited

I suggest avoiding the term "non-async" when explaining the problem. Async is short for asynchronous and a non-asynchronous function is a synchronous function and that is not what you mean. e.g. Although loopWithDelayNonAsync isn't an instance of AsyncFunction it is an "async" (commonly used in code as shorthand for "asynchronous") function as it returns a Promise (which represents the fulfillment of an asynchronous operation).

One simple way to avoid this ambiguity would be: "How would you go about implementing a state-machine-style version of the following async function without using the async/await keywords ..."

Collapse
 
noseratio profile image
Andrew Nosenko • Edited

I was actually contemplating how to avoid this ambiguity, but decided to stick with "non-async" for its literal meaning, no async modifier. Moreover, an asynchronous (in common sense) function doesn't even have to return a Promise, it can be any thenable. Anyhow, it's a great point, thank you. I agree I need to change the wording, and I think I'd also add "... without yield" :)