Optionally async functions in JavaScript

Or: avoiding the what-color-is-your-function trap.

§ tl;dr

GenSync is a really neat library that lets you write one function and use it in both sync and async contexts.

§ Intro

async functions are great! Everyone loves them! But, well, they tend to get everywhere, and sometimes you need to do things synchronously. And if you want to do basically the same thing in a synchronous context, you get to copy-paste and rewrite your entire function. That sucks.

GenSync is the fix.

§ A quick example

Instead of writing both

function doSomething() {
  let value = init();
  let thing = syncDoInnerThing();
  doSomeLogic(thing);
  doSomeOtherLogic(thing);
  return thing;
}

and

async function doSomethingButAsync() {
  let value = init();
  let thing = await asyncDoInnerThing();
  // ^ only line changed from doSomething
  doSomeLogic(thing);
  doSomeOtherLogic(thing);
  return thing;
}

you can write

let doInnerThing = gensync({
  sync: syncDoInnerThing,
  async: asyncDoInnerThing,
});

let { sync: doSomething, async: doSomethingButAsync } = gensync(
  function* () {
    // note: `function*` instead of `async function`
    let value = init();
    let thing = yield* doInnerThing();
    // note: `yield*` instead of `await`
    doSomeLogic(thing);
    doSomeOtherLogic(thing);
    return thing;
  }
);

and then get both doSomething and doSomethingButAsync with no copy-pasting required!

§ A real example

OK, that was maybe a little too quick, and besides, when would you even want to do that?

Here's a real example. Let's say you want to write a utility which finds the transitive dependencies of an ES module. Easy enough:

import * as babel from '@babel/parser';

export async function getTransitiveDependencies({ entrypointURL, readURL }) {
  let queue = [entrypointURL.toString()];
  let seen = new Set();
  while (queue.length > 0) {
    let nextURL = queue.shift();
    if (seen.has(nextURL)) {
      continue;
    }
    seen.add(nextURL);
    let source = await readURL(nextURL);
    queue.push(...dependenciesFromModule(source, nextURL));
  }
  return [...seen];
}

function dependenciesFromModule(source, sourceURL) {
  let parsed = babel.parse(source, { sourceType: 'module' });
  return parsed.program.body
    .filter(line => ['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(line.type) && line.source != null)
    .map(line => new URL(line.source.value, sourceURL).toString());
}

And since you let the user specify how read to files, it works both in node:

import { getTransitiveDependencies } from './get-transitive-deps.mjs';
import * as fsPromises from 'node:fs/promises';
import * as url from 'node:url';

(async () => {
  let deps = await getTransitiveDependencies({
    entrypointURL: url.pathToFileURL('src/main.js'),
    readURL: async u => await fsPromises.readFile(url.fileURLToPath(u), 'utf8'),
  });
  console.log(deps);
})().catch(e => {
  console.error(e);
});

and on the web:

import { getTransitiveDependencies } from './get-transitive-deps.mjs';

(async () => {
  let deps = await getTransitiveDependencies({
    entrypointURL: new URL('src/main.js', window.location),
    readURL: async u => await (await fetch(u)).text(),
  });
  console.log(deps);
})().catch(e => {
  console.error(e);
});

All is well.

But then you want to use your fancy new utility in a custom ESLint rule, and... you can't. ESLint rules must complete synchronously, and your utility uses await. Even if the caller provides you with a synchronous readURL, like fs.readFileSync, there's no straightforward way for your function to use it and still be able to handle the user giving you fetch, which is necessarily async.

There's nothing for it but to copy-paste your whole utility and remove the async and await keywords. Ugh.

Enter GenSync

GenSync solves this problem for you. All you have to do is replace async function f() {} with let { sync: syncF, async: asyncF } = function* f() {}, replace all awaits with yield*s, and wrap up any possibly-asynchronous functions you want to call with gensync({ sync: f, async: f }). Like this:

import { default as gensync } from 'gensync';

let wrap = f => gensync({ sync: f, async: f });

let { sync, async } = gensync(
  function* getTransitiveDependencies({ entrypointURL, readURL }) {
    let queue = [entrypointURL.toString()];
    let seen = new Set();
    while (queue.length > 0) {
      let nextURL = queue.shift();
      if (seen.has(nextURL)) {
        continue;
      }
      seen.add(nextURL);
      let source = yield* wrap(readURL)(nextURL);
      queue.push(...dependenciesFromModule(source, nextURL));
    }
    return [...seen];
  }
);

export {
  sync as getTransitiveDependenciesSync,
  async as getTransitiveDependenciesAsync,
};

Now you have both sync and async versions of your function available, with no copy-pasting required. You can call getTransitiveDependenciesAsync just as before, but you can also call getTransitiveDependenciesSync and pass it a sync readURL function and get a sync result, like you need for your hypothetical ESLint rule.

// this works when you can be async
let asyncDeps = await getTransitiveDependenciesAsync({
  entrypointURL: url.pathToFileURL('src/main.js'),
  readURL: async u => await fsPromises.readFile(url.fileURLToPath(u), 'utf8'),
});
console.log(asyncDeps);

// this works when you need to be sync
let syncDeps = getTransitiveDependenciesSync({
  entrypointURL: url.pathToFileURL('src/main.js'),
  readURL: u => fs.readFileSync(url.fileURLToPath(u), 'utf8'),
});
console.log(syncDeps);

Isn't that sweet?

§ Some other tricks

Two more useful tidbits:

First, if you know what the sync and async versions of a function are, you can write

let ambi = gensync({ sync: theSyncVersion, async: theAsyncVersion });

Then yield* ambi() will call the right version, depending on whether the function was called as sync or as async.

Second, if for some reason you need to branch on which context you were called in, you can do that with a simple helper:

let isAsync = gensync({
  sync: () => false,
  async: () => Promise.resolve(true),
});

// later
if (yield* isAsync()) {
  /* async-specific code */
} else {
  /* sync-specific code */
}

§ Parting thoughts

I hope we in the JS community can make broader use of this library, and break free of the trap of writing functions which can only be used in one context or the other. Babel is already doing it - if you've ever wondered how they manage to have both transformSync and transformAsync, now you know. If you end up in a similar situation, I encourage you to follow their lead.

§ Credits

Major kudos to my fellow TC39 delegate @loganfsmyth for writing this library.

bakkot