Never Mix Promises and Callbacks in NodeJS
spin.atomicobject.com
spin.atomicobject.com
The problem here is that chained Promise reactions, like .then(A).catch(B) can indeed call both A and B if A throws, which in this case is very bad if you can only call one of them.
The correct solution is to use an error handler in .then():
function fetchFirstUser(iswhatIwant, callback) {
knex.first().from("users").then((user) => {
callback(user);
}, (e) => {
callback(null);
});
}
But anytime you're invoking callbacks you also have to consider the case where the callback throws - which is one reason returning Promises is generally easier to get correct than invoking callbacks - so you _also_ need a .catch(): function fetchFirstUser(iswhatIwant, callback) {
knex.first().from("users").then((user) => {
callback(user);
}, (e) => {
callback(null);
}).catch((e) => {
// oh no, callback() threw! bad callback!
console.error(e);
});
}
This might seem like a lot, but I blame callbacks in general for the extra work that needs to be done. If you can avoid callbacks, do, because Promises all the way through would be so much simpler: function fetchFirstUser(iswhatIwant, callback) {
return knex.first().from("users");
}
And push error handling out to the initiator. function fetchFirstUser(isWhatIWant, callback) {
knex.first().from('users')
.then(user => ({ user }))
.catch(e => ({ e }))
.then(({ user, e }) => {
if (user) {
callback(user);
} else {
callback(e);
}
});
}
By only calling your callback function after a catch statement, you guarantee that (a) an exception generated by the callback will not be swallowed by the catch statement and that (b) the callback is called exactly once in the promise chain.For golfing purposes, you can remove a branch too:
function fetchFirstUser(isWhatIWant, callback) {
knex.first().from('users')
.then((user) => ({ user }), (e) => ({ e }))
.then(({ user, e }) => callback(e, user))
.catch((e) => console.error(e));
}The problem here isn't that they are being swallowed, it is that the tests have a constraint that the callback be called only once, which is conveniently the behavior of a then applied to the promise you would return.
If you are using promises, understand how they work. The same for callbacks.
My example is contrived, but in the larger project that I was working in, almost all of the existing code used callbacks. Refactoring all that code to use promises, just to accommodate the two or three places that actually needed them, probably would not have been worth it.
// Promisifying a standard callback
const myAsyncMethod = Bluebird.promisify(myCallbackMethod);
// Or if you have weird callback passing and need
// to manipulate the callback method being passed around
function myAsyncMethod(...args) {
return Bluebird.fromCallback(cb => myAsyncMethod(cb, ...args));
}I'd suggest making all functions that take callbacks also return Promises to make the transition easier:
async function fetchFirstUser(iswhatIwant, callback) {
const safeCallback = (user) => {
try {
callback(user);
} catch (e) {
console.error(e);
}
};
try {
const user = await knex.first().from("users");
safeCallback(user);
return user;
} catch (e) {
safeCallback(null);
throw e;
}
}
You can put this in a utility (and I'd recommend standardizing on Node's callback style): async doWithCallback(promise, callback) {
const safeCallback = (error, result) => {
if (callback == null) {
return;
}
try {
callback(null, result);
} catch (e) {
console.error(e);
}
};
try {
const result = await promise;
safeCallback(null, result);
return result;
} catch (e) {
safeCallback(e);
throw e;
}
}
async function fetchFirstUser(iswhatIwant, callback) {
return doWithCallback(knex.first().from("users"), callback);
}
At some point you can start warning when callbacks are passed in, then throwing, then remove the argument.Takeaways:
- It has the wrong signature! The callback is the second parameter but is being passed as the first, so it will never be called.
- Even if that's a typo, don't catch what you intend to throw as an exception. Use the callback as the second parameter of the `then` and let it fail. The error will stil l be swallowed, but in a more predictable way.
- Better yet, use async/await if possible! It makes promises a joy to work with.
It works for every async function in node, except for `fs.exists` which inexplicably does not provide an error in the callback.
Speaking of which, when is node going to actually start supporting promises natively? I've been using this denodeify hack for years!
function asCallback(promise, callback) {
return Promise.resolve(promise)
.then(result => callback(null, result))
.catch(callback)
}
// returns a new fn that takes same args which returns promise instead
function asPromise(fn) {
return (...args) => new Promise((resolve, reject) => {
fn(...args, (err, result) => {
if (err) return reject(err);
resolve(result)
})
})
}If you call function x with a callback you either handle it immediately via your callback or you have to use some other paradigm to make it compose with other async code. You get no return value allowing you to write code that looks synchronous and forget about the beauty of async/await you've completed abandoned it with callbacks. If you use Promises you can do as you wish and combine them as you wish as they hold values and you can use them multiple times trivially with other Promises,
async makes it easy and I don't see a downside to using it everywhere possible.
You could at best use `setTimeout` or `nextTick` to escape from `Promise`'s call stack and throw there.
promise.then(function(){
throw new Error('Oh no, not caught');
}, function(err){
});
But this will catch it: promise.then(function(){
throw new Error('Oh no!');
}).catch(function(err){
// Caught it ;)
});
As will this: promise.then(function(){
throw new Error('Oh no!');
}).then(null, function(err){
// Caught it ;)
});
Point being that your error handling need to be absolute last, and not paired with a success handler.https://qubyte.codes/blog/promises-and-nodejs-event-emitters...
(The title is a bit overstated, since it's really about the specific example of unhandled error emissions in a promise chain.)
Regardless of the merits or problems of either post, a lesson is to be careful around promises when errors are involved.
process.on("unhandledRejection", err => {
console.error("Uncaught Promise Error: \n" + err.stack);
console.error(err);
});I've once had a very nasty bug keeping me busy for days, where somewhere in a complex structure of multiple promises I somehow forgot to return a call to a resolve. With all these new Promise(), .then(), .catch(), resolve's and reject's nested it's very easy to miss one, and very hard to read where the bug sits.
Maybe it's just me, but I never had such a severe bug with the simplicity of the standard async callback pattern. I Never used promises again, still enjoying that decision.
Promises allow, enable and encourage composition.
If you use them correctly no single promise should ever be so large that this becomes a problem.
That said, you can write spaghetti in any language and platform.
If you're the kind to write 100+ line async functions, no framework or technique will help you keep your code tight and solid.
I also enjoy refactoring callback code, only without the use of Promises, because I don't prefer them to compose and create a nice readable flow. There is a little learning curve though, but it's not so bad.
I haven't kept up with JS trends recently. Are these promise wrapper libraries like Bluebird still relevant now that ES6 is out?
E.g., providing `promisify` wrappers for callback driven code and functional collection methods like `map` / `filter` / `reduce`.
"Callback Hell" is FUD spread by people who somehow missed the memo about the fact that you can add functions to your code. http://callbackhell.com explains it quite well.
In running away from "Callback Hell" the JavaScript community ran straight into "Control Structure Hell" which is far, far worse.