A better async/reactive function design in JS (or any other languages) — the Universal Currying Callback (UCC) Design
For the principle "Don't call us, we'll call you", the modern function design uses callbacks, Promise, or monad/stream-based techniques (e.g. Rx) to let you subscribe the async results.
The following is a usual JS function implementation using the callback for async:
function register(username, password, email, callback) {
// processing async work
... {
if (err) callback(err, null); // failed
else callback(null, results); // success
}
}
Or using Promise to wrap callback events:
function register(username, password, email) {
return new Promise((resolve, reject) => {
// processing async work
...
if (err) reject(err); // failed
else resolve(results); // success
};
}
Although those don't bring you any trouble right now, there is a more elegant way to unify async world.
Make your function design from:
// Node.js callback design
sum(1, 2, 3, (err, res) => {
...
});
// Promise style
sum(1, 2, 3).then(...)
// RxJS style
sum(1, 2, 3).subscribe(...)
to this one:
// Universal Currying Callback Design
sum(1, 2, 3)(...callbacks);
// For example:
sum(1, 2, 3)(value => {
...
});
Why? Let's see examples.
We'll discuss the callback API design later. Right now, we discuss 'currying' and 'universal' first.
Say, if you have a variadic function which be able to accept any number of arguments, you can't design your function API like this:
// You can't add any other argument after variadic arguments.
// The argument `...values` should be the last argument.
function sum(...values, callback) { // ==> Unexpected token
...
}
Yes, you could put callback first and then accept the variadic arguments, but it is not good readability.
You can use UCC design to resolve this:
// ES5 style
function sum(...values) {
return function(callbacks) {
...
}
}
// ES6+ style
const sum = (...values) => (callbacks) => {
...
}
// Now you can pass any number parameters to the function,
// and then get the results with the callbacks:
sum(1, 2, 3, 4, 5, 6)(callbacks);
JS supports optional arguments:
// `greeting` is optional argument;
// if you don't pass a value to it, it will use 'Hello'.
function sayHello(name, greeting = 'Hello') {
return `${greeting}, ${name}.`;
}
// If you pass fewer parameters to the function, JS will pass `undefined` to
// the arguments, and try to use the **default** value you set instead of.
sayHello('Xaree'); // => 'Hello, Xaree.'
sayHello('John', 'Hola'); // => 'Hola, John.'
What if a function has many optional arguments and ends with a callback? You need to pass many undefined
first and then pass your callback:
function go(direction = 'East', steps = 1, speed = 1, callback) {
...
}
// If you want to call the function with default values with a callback,
// you need to pass many `undefined` values first.
go(undefined, undefined, undefined, callback);
This is so ugly, and what if using UCC design?
// ES5 style
function go(direction = 'East', steps = 1, speed = 1) {
return function(callback) {
...
}
}
// ES6+ style
const go = (direction = 'East', steps = 1, speed = 1) => (callback) => {
...
}
// Call with default values
go()(callback);
More clear, right?
If making your library be Promise-based, then it's hard to be Rx-based.
Although Rx has a contructor
Rx.Observable.fromPromise()
, the observable will only receive one value (result), not continuous streaming values.
Furthermore, if making your library be Rx-based, it means your user can't use your library with other reactive programming library (bacon.js, highland.js, sodium, xstream, etc.) directly.
With UCC design, it's really easy convert a function to any flavor you like:
// UCC function
const go = (direction = 'East', steps = 1, speed = 1) => (callback) => {
...
}
// universal callback API
go('Eest', 2)(callback);
// turn a UCC function to return a Promise
const promiseGo = promisify(go);
promiseGo('Eest', 2).then(...);
// turn a UCC function to return a Rx Observable
const rxGo = rxify(go); // also able to convert to other FRP library's monad
rxGo('Eest', 2).subscribe(...);
We'll see how to convert a UCC function to your flavor later.
Although in Node.js community, the error-first callback ((error, results) => {}
) is the convention, the API design has problems:
fs.readFile('/foo.txt', function(err, data) {
if (err) {
// handle error
} else {
// handle data
}
});
A single callback accepting all kinds of events will need additional condition tests (if...else...
). The function fs.readFile
has already known what kind of event it will send:
// pseudo code
fs.readFile(filename, callback) {
// if error
if (err) {
// It has checked it's an error event.
// Why does the callback need a repeated check again?
callback(err, undefined);
return;
}
callback(undefined, data);
}
For the principle "don't use a condition test until you really need it" or "less condition tests" (I said it), using different callbacks for data events and error events is better. More if...else...
means you need more tests and it could be error-prone.
I made a proposal for a better async function API design — universal currying callback design:
- Universal (U): Adopting a unified callback API design allows the function to be easily converted to Promise, Rx, or any other async handler. This will decouple an opinion async solution from code. Users can choose their flavor on their own when using.
- Currying (C): making a callback-based async function be compatible with variadic arguments and/or optional arguments.
- Callback (C): no any async solution dependency to be reactive. You can barely use the UCC functions without Promise/Rx.
Principle: make your functions to adopt UCC design first, and turn them into your async solution flavor.
If you make your library to be Promise-based first, you need lots of work to make them Rx-based. Coupling an async solution flavor to your code might get you into trouble in the future when you want to replace it with another async solution.
Promise or Rx may be old-fasioned one day. The UCC design focuses to provide the basic async/reactive needs, and could be easily converted to any flavor you want.
Just one more question, I thought the convention in currying was to postpone data args, but it seems you have them as the first args. Would swapping the order allow composition of async functionality?