Async/await without promises

Dominic Mayers
Jul 22, 2017 · 7 min read

Note added: The original question was whether it is possible to have try objects along with promises within async/await in an efficient manner. This implementation does not answer this question. It is connected though: if we have try objects along with promises, it is natural to have asynctry functions (that return try objects) along with the possibility to await promises.

The key idea behind the proposed implementation is simply that we don’t need the pending state of promises within await statements. In synchronous calls, the returned value is not immediately available, but we don’t say that this value is in a pending state — that concept is not useful. The same is true for the pending state of a promise in an await statement.

The await calls in an async function are ordinary synchronous calls — only the async function itself is asynchronous. Any computation of a value using asyncronous calls is itself asynchronous, as far as the computation of the value is concerned, but the converse is not true: the async function can be asynchronous without making asynchronous calls. The async function is asynchronous because its implementation uses a generator that is called with a next method that returns before any intermediary value is even computed. The computation of these intermediary values is itself perfectly synchronous.

Given that we are in an ordinary synchronous context, the pending state of promises in await statements is not useful. So, instead of using promises we use try objects. A promise has three possible states: pending, resolved and rejected. A try object has only the last two states. We can only access the value/exception of a promise using callbacks in the method then . The value of a try object is returned by the method endtry— no need for a then method. The exception can be caught in the catch method, but can also be caught by a try/catch block around the endtry statement.

Waiting for the computation of a try object is the same thing as awaiting the associated promise. We simply do not compute the intermediary promise. We directly compute the try object. We lose the possibility to use a catch on the intermediary promise, but using the catch method on the final try object is the same. On purpose, we did not implement a then method on try objects.

The proposed implementation does not have any syntactic sugar. There is no keyword, say an asynctry keyword, analogous to the async keyword. Instead of asynctry function fctname(a,b,c) {...} we must write var fctname = GeneratorYieldableTryConstruct(function * (holder, a,b,c) {...}); . The returned function fctname is an asynctry function. The generator that is passed as an argument must receive an additional parameter holder and explicitly pass holder.gen to every called asynctry functions within. It should not be hard to add syntactic sugar to support a keyword asynctry and to hide the use of holder and holder.gen.

The proposed implementation does not use promises, but when an asynctry function is used with null instead of holder.gen as first argument, it returns a promise as usual. So, we have all the features of ordinary async functions, but we get rid of the unnecessary promise in an await statement. Also, we can create an asynctry function from a promise function.

The try objects

We start with the try objects themselves. This is straightforward. There is no subtleties. The state of a try object is either the value or the exception. This is passed to the constructor Try using an array eord = [err,data] . We do not store eord in a property. For convenience, the property fc is a function that returns the value or throw the exception.

The method catch simply replaces fc with a new function that is simply a try/catch block around the original fc whereas the method endtry simply executes fc and returns the value.

function Try (eord)
{
this.fc = function ()
{
if (eord.length == 1) throw eord[0]
else return eord[1];
};
}
Try.prototype =
{
catch: function (handler)
{
var g = this.fc.bind(this);
this.fc = function ()
{
try {return g()} catch (e) { return handler(e) };
}
return this;
},
endtry: function ()
{
return this.fc();
}
};
function TryConstruct (f)
{
return function (...args)
{
var eord;
try
{
eord = [null, f(...args)];
}
catch(e)
{
eord = [e];
}
return new Try(eord);
}
}

Asynctry functions from generators

The function GeneratorYieldableTryConstruct creates an asynctry function from a generator. The generator must have a first argument holder , call other asynctry functions with a yield statement and pass holder.gen to them. Here is an example of usage :

var calledasynctry = 
PromiseYieldableTryConstruct((msg) => Promise.resolve(msg));
var callerasynctry = GeneratorYieldableTryConstruct(
function * (h, msg)
{
return (yield calledasynctry(h.gen, msg)).endtry();
}
);
var tryobj = yield callerasynctry(h.gen, 'Hello World!');console.log(tryobj.endtry()); // Hellow World!

The yield and the endtry together do the same as an await statement. With some syntactic sugar, the extra endtry could be hidden. However, it can also be useful to use a catch method on the intermediary try object.

Implementation of GeneratorYieldableTryConstruct

The returned asynctry function is like a wrapper over the generator, which is passed as an argument, but we need an intermediary wrapper, wrapgen, which will do most of the (wrapping) job.

The asynctry function is an extra wrap over wrapgen, only because unlike an object that has a thiskeyword, a generator does not seem to know itself. So, the asynctry function passes the argument holder to wrapgen . This holder gets the generator itself in its property holder.gen . Then the asynctry function calls the next method, which starts the computation. There is a subtlety, if there is no caller, but we will come back to this later.

If we ignore the case with no caller, wrapgen is also simple. It basically, executes the generator (via delegation) to get the value or the exception, construct the try object and returns it to the caller using the next method.

In a previous implementation, the case with no caller did not return a promise or any value. It simply executed the passed generator. It checked that there was no exception with a try/catch block, because, as in the case of promises, we did not accept uncaught asynchronous exceptions. However, to really implement an async/await construct, the asynctry functions must return promises.

One challenge to return a promise is that the original next method that starts the computation of the asynctry function returns the value that is first yielded. It ignores the subsequent next calls and thus subsequent yield statements — this is why it is said asynchronous. Another challenge is that the yield statement cannot occur directly within the body of the promise executor.

To address these issues, the promise executor uses executorcallerfct to create a generator executorcaller, which can get the try object from a subsequent next method. Once it has the try object, it can resolve or reject the promise on behalf of the executor. The generator lies in the scope of wrapgen so that its next method can be called after the promise executor has been executed and the promise returned, but not necessarily resolved.

The only additional subtlety is that when there is no caller, before the computation starts, an extra yield is needed in wrapgen to allow the generator to return the promise. So, the asynctry function does an extra call let prom = g.next() before it starts the actual computation with a subsequent g.next(). After the computation is started, the promise prom.value is immediately returned by the asynctry function, before the computation has the time to do anything. The returned promise is pending and the started computation will eventually resolve or reject it.

var dummypromise = Promise.resolve(1);function GeneratorYieldableTryConstruct (gen)
{
function * executorcallerfct (result, reject)
{
var tryobj = yield;
try
{
result(tryobj.endtry())
}
catch (e) {reject(e)}
}
function* wrapgen (holder, gen, caller, ...args)
{
var eord;
var executorcaller;
if(!caller)
{
var p = new Promise((result, reject) =>
{
executorcaller = executorcallerfct(result, reject);
executorcaller.next();
});
yield p;
}
try
{
let res = yield* gen(holder, ...args);
eord = [null, res]
}
catch (e) { eord = [e]}
var tryobj = new Try(eord);
var gg;
if (caller)
{
try{caller.next(tryobj)}
catch (e) {
// If it wasn't, this makes it asynchronous.
dummypromise.then(() => caller.next(tryobj))
};
}
else
{
executorcaller.next(tryobj);
}
}
return function (caller, ...args)
{
var holder = {};
var g = wrapgen(holder, gen, caller, ...args);
holder.gen = g;
if(!caller)
{
let r = g.next();
g.next()
return r.value;
}
else
{
g.next();
}
}
}

Asynctry functions from promise functions

The function PromiseYieldableTryConstruct creates an asynctry function from a promise function. This is actually quite straightforward. So, we simply post the code.

function PromiseYieldableTryConstruct(pfct)
{
return function (caller, ...args)
{
pfct(...args).then(v => caller.next(new Try([null, v])), e => caller.next(new Try([e])))
}
}

Comparison of performance

Let’s consider this simple async function:

async function testedasync()
{
var r = await Promise.resolve(1);
return r;
}

The corresponding asynctry function will be more complicated because we do not have any syntactic sugar. Also, we can only await asynctry function. So, the code must first create an asynctry function from the promise using PromiseYieldableTryConstruct :

var testedasynctry = GeneratorYieldableTryConstruct(function * (h)
{
var r = (yield PromiseYieldableTryConstruct(() => Promise.resolve(1))(h.gen)).endtry();
return r;
});

We “awaited” these two functions 10,000 times. Our code based on try objects was about 230% slower. If the code is optimized, the computation of the intermediary promise is perhaps not a big factor.

Some tests

console.log('\nTest reject1');  
function reject1 (message) { console.log('Throwing an exception'); throw `Message: ${message}`; }
var tryreject1 = TryConstruct(reject1);
var res = tryreject1('reject1 message')
.catch(e => {console.log('Rethrowing in yield statement'); throw e;})
.catch(e => {console.log('Recatching in yield statement'); return e})
.endtry();
console.log(res);
console.log('\nTest reject2');
function reject2 () { console.log('Throwing an exception'); throw `No message`; }
var tryreject2 = TryConstruct(reject2);
var res = tryreject2('reject2 message')
.catch(e => {console.log('Rethrowing in yield statement'); throw e;})
.catch(e => {console.log('Recatching in yield statement'); return e})
.endtry();
console.log(res);
console.log('\nTest success');
function success (message) { return `Success: ${message}`;}
var trysuccess = TryConstruct(success);
var res1 = trysuccess('success message')
.catch(e => {console.log('Rethrowing in yield statement'); throw e;})
.catch(e => {console.log('Recatching in yield statement'); return e})
.endtry();
console.log(res1);
var test = GeneratorYieldableTryConstruct(function * (h)
{
console.log('\nTest rejprom');
var rejprom = Promise.reject('Failure from rejprom')
var tryrejprom = PromiseYieldableTryConstruct(() => rejprom);
var tryobj1 = yield tryrejprom(h.gen);
var res1 = tryobj1.catch(e => {console.log(e); return "No result";} ).endtry();
console.log(res1);
console.log('\nTest resprom');
var resprom = Promise.resolve('Success from resprom')
var tryresprom = PromiseYieldableTryConstruct(() => resprom);
var tryobj2 = yield tryresprom(h.gen);
var res2 = tryobj2.catch(e => {console.log(e); return "No result";} ).endtry();
console.log(res2);
console.log('\nTest gentest');
var calledasynctry = PromiseYieldableTryConstruct((msg) => Promise.resolve(msg));
var callerasynctry = GeneratorYieldableTryConstruct(function * (h, msg)
{
return (yield calledasynctry(h.gen, msg)).endtry();
});
var tryobj = yield callerasynctry(h.gen, 'Hello fron gentestfct');
var res3 = tryobj.endtry();
console.log(tryobj.endtry());
return `res1: ${res1}, res2: ${res2}, res3: ${res3}`;
});
var p = test();setTimeout(() =>
{
console.log('\nResult of testfct:');
console.log(p);
}, 0);

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade