It is fair to say promises are just syntactic sugar. Everything you can do with promises you can do with callbacks. In fact, most promise implementations provide ways of converting between the two whenever you want.
The deep reason why promises are often better is that they're more composeable, which roughly means that combining multiple promises "just works", while combining multiple callbacks often doesn't. For instance, it's trivial to assign a promise to a variable and attach additional handlers to it later on, or even attach a handler to a large group of promises that gets executed only after all the promises resolve. While you can sort of emulate these things with callbacks, it takes a lot more code, is very hard to do correctly, and the end result is usually far less maintainable.
One of the biggest (and subtlest) ways promises gain their composability is by uniform handling of return values and uncaught exceptions. With callbacks, how an exception gets handled may depend entirely on which of the many nested callbacks threw it, and which of the functions taking callbacks has a try/catch in its implementation. With promises, you know that an exception which escapes one callback function will be caught and passed to the error handler you provided with .error()
or .catch()
.
For the example you gave of a single callback versus a single promise, it's true there's no significant difference. It's when you have a zillion callbacks versus a zillion promises that the promise-based code tends to look much nicer.
Here's an attempt at some hypothetical code written with promises and then with callbacks that should be just complex enough to give you some idea what I'm talking about.
With Promises:
createViewFilePage(fileDescriptor) {
getCurrentUser().then(function(user) {
return isUserAuthorizedFor(user.id, VIEW_RESOURCE, fileDescriptor.id);
}).then(function(isAuthorized) {
if(!isAuthorized) {
throw new Error('User not authorized to view this resource.'); // gets handled by the catch() at the end
}
return Promise.all([
loadUserFile(fileDescriptor.id),
getFileDownloadCount(fileDescriptor.id),
getCommentsOnFile(fileDescriptor.id),
]);
}).then(function(fileData) {
var fileContents = fileData[0];
var fileDownloads = fileData[1];
var fileComments = fileData[2];
fileTextAreaWidget.text = fileContents.toString();
commentsTextAreaWidget.text = fileComments.map(function(c) { return c.toString(); }).join('\n');
downloadCounter.value = fileDownloads;
if(fileDownloads > 100 || fileComments.length > 10) {
hotnessIndicator.visible = true;
}
}).catch(showAndLogErrorMessage);
}
With Callbacks:
createViewFilePage(fileDescriptor) {
setupWidgets(fileContents, fileDownloads, fileComments) {
fileTextAreaWidget.text = fileContents.toString();
commentsTextAreaWidget.text = fileComments.map(function(c) { return c.toString(); }).join('\n');
downloadCounter.value = fileDownloads;
if(fileDownloads > 100 || fileComments.length > 10) {
hotnessIndicator.visible = true;
}
}
getCurrentUser(function(error, user) {
if(error) { showAndLogErrorMessage(error); return; }
isUserAuthorizedFor(user.id, VIEW_RESOURCE, fileDescriptor.id, function(error, isAuthorized) {
if(error) { showAndLogErrorMessage(error); return; }
if(!isAuthorized) {
throw new Error('User not authorized to view this resource.'); // gets silently ignored, maybe?
}
var fileContents, fileDownloads, fileComments;
loadUserFile(fileDescriptor.id, function(error, result) {
if(error) { showAndLogErrorMessage(error); return; }
fileContents = result;
if(!!fileContents && !!fileDownloads && !!fileComments) {
setupWidgets(fileContents, fileDownloads, fileComments);
}
});
getFileDownloadCount(fileDescriptor.id, function(error, result) {
if(error) { showAndLogErrorMessage(error); return; }
fileDownloads = result;
if(!!fileContents && !!fileDownloads && !!fileComments) {
setupWidgets(fileContents, fileDownloads, fileComments);
}
});
getCommentsOnFile(fileDescriptor.id, function(error, result) {
if(error) { showAndLogErrorMessage(error); return; }
fileComments = result;
if(!!fileContents && !!fileDownloads && !!fileComments) {
setupWidgets(fileContents, fileDownloads, fileComments);
}
});
});
});
}
There might be some clever ways of reducing the code duplication in the callbacks version even without promises, but all the ones I can think of boil down to implementing something very promise-like.
Oh it's possible. It just doesn't look like what you want it to look like.
What you're complaining about is the use of output ports1,2,3 to communicate results rather then returning results. Does this make testing more complicated? Yes. Then why do it? Because it gives you another layer of polymorphism.
When you return, you're returning to the caller. You have no choice. Nowhere else to go. That means the caller has to know how to deal with the result. If you have an output port you can send the result off to whatever knows how to listen to you. That is powerful.
Why is that powerful? Because you're always going forward. Always facing towards the abstraction. Using things the way they were meant to be used rather then sneaking up behind them. Abstractions work better when you're not hacking them.
To test this you have to know how design things that know how to listen to an output port. But you were going to do that anyway. So what's the problem?
The problem is so many people get caught up thinking structurally. "All I'm doing is testing that a method got called". No. You're testing that a message, that communicates a result, got sent. If, in this mini output port language, that looks like a method call, fine. "But that's an implementation detail!" No. That's part of the output port language. What anything does with that message is up to them. What gets done with it is the implementation detail.
"But it's such a pain to test!" Well yes it is. But you decided to use it so it's your own fault. "So I should never use it?" No, you should use it when it does something useful for you, when you understand it, and when you think it's worth the pain it causes.
Best Answer
In my opinion, you can use one method (for each service function) and differentiate callbackful and callbackless version by either allowing
null
callback to be passed (idea#1) or by using callback to be registered bysetCallback
like your second idea (idea#2).Callback as argument (idea#1)
Callback as a field (idea#2)