§ 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 await
s 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.