Skip to content

Instantly share code, notes, and snippets.

@xareelee
Last active September 11, 2018 20:13
Show Gist options
  • Save xareelee/e85c8b2134ff1805ab1ab2f1c8a037ce to your computer and use it in GitHub Desktop.
Save xareelee/e85c8b2134ff1805ab1ab2f1c8a037ce to your computer and use it in GitHub Desktop.
A better async/reactive function design in JS (or any other languages) — the Universal Currying Callback (UCC) Design

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.

Introduce the Universal Currying Callback (UCC) Design

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.

What's wrong with the traditional async function API design?

1. Hard to add a callback to variadic functions

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);

2. Hard to make function use optional paramter with callback

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?

3. Using an opinion async event library first will reject users to use another async event library.

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.

4. Node.js callback API design is not good

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.

Summary

I made a proposal for a better async function API design — universal currying callback design:

  1. 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.
  2. Currying (C): making a callback-based async function be compatible with variadic arguments and/or optional arguments.
  3. 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.

UCC function API Design and Use (callback part)

Naming convention

A UCC function name should end with _$ (or $UCCF) to remind the users that it needs a currying callback.

// Wrong: lack a currying callback
userLogin_$(account, password);

// Right: `_$` remind you the function return a higher-order function
userLogin_$(account, password)(onNext, onError, onEnd);

Univsersal Currying Callback Function

A UCC function returns a higher-order function, called Universal Callback Function (UC function). When you adopt UCC design, the univsersal callback function takes three callbacks: onNext, onError, and onEnd. It's very similar to most FRP libraries:

const silenced = function(){}; // a callback doing nothing

// ES6+ style
const userLogin_$ = (account, password) => 
  (onNext = silenced, onError = silenced, onEnd = silenced) => {
  // resolve here
  async_signin_api(account, password, (err, res) => {
    if (err) {
      onError(err);
    } else {
      onNext(res);
      onEnd();
    }
  }
}

// use UCC function
userLogin_$(account, password)(x => {
  console.log('user id: ', x.id);
}, err => {
  console.log('User login is failed. Error: ', err);
}, () => {
  console.log('User login is success.');
});

The three callbacks are optional in UCF. You should provide a silenced callback as default if undefeined.

This callback signatures are compatible but slightly different from Rx design:

  • onNext: (nextValue) => {...}
  • onError: (error, continue? = false) => {...}
  • onEned: (endStatus? = undefined) => {...}

The main differences are two more optional arguments:

  • An optional argument continue to onError: you can pass continue:true (default is false) to onError to tell that there are more values or errors might be sent. In most of cases, you don't want to continue after an error occurs.
  • An optional argument endStatus to onEnd: You can pass a status object to onEnd to provide more information about how/why it ends.

Although UCC design supports the addtional optional argument, it doesn't mean you need to use them. You can still use UCC functions like using Rx.

Convert UCC function to other async handlers

to Promise

// The MIT license. Copyright (c) 2016-present Kang-Yu Lee (a.k.a. Xaree Lee)
// convert any UCC function to Promise
const promisify = (uccf) => (...args) => {
  const ucf = uccf(...args);
  return new Promise((resolve, reject) => {
    ucf(
      x => resolve(x),
      err => reject(err),
      end => resolve(end)
    );
  });
}


// generate a Promise for user login
const userLoginPromise = promisify(userLogin_$)('[email protected]', 'youcantseeme');
userLoginPromise.then(...);

The promisify() will only send the first value or the first end event to the resolver. Other values will be ignored. If you use UCC function to send multiple values, you need to implement you own version of promisify() to concat or fold those values into one result and send it to Promise's resolver.

Note: Promise only accepts to resolve once.

to RxJS

UCC design is fully compatible with Rx subscription:

// The MIT license. Copyright (c) 2016-present Kang-Yu Lee (a.k.a. Xaree Lee)
// convert any UCC function to Rx.Observable
const rxify = (uccf) => (...args) => {
  const ucf = uccf(...args);
  return Rx.Observable.create(subscriber => {
    ucf(
      x => subscriber.next(x),
      err => subscriber.error(err),
      end => subscriber.complete()
    );
  });
}

// generate a Rx.Observable for user login
const userLogin$ = rxify(userLogin_$)('[email protected]', 'youcantseeme');
userLogin$.subscribe(...);

The rxify() will discard the two optional arugments continue and endStatus from the universal callbacks to Rx.Observable. If you want to keep these contexts, you should build your version of rxify().

Some FRP may support different endings other than error/onError or completed/onComplete, for example, interrupted/onInterrupt or cancelled/onCancel. You could use UCC optional argument end to distinguish how it ends with addtional info.

Some async handler system may support multiple errors. You could collect those errors and send them all at the end.

@loganpowell
Copy link

This looks pretty amazing @xareelee! So versatile ❤️ Do you have any example apps using this pattern with GraphQL and/or RxJS? I saw your suggestion on this thread:

graphql/graphql-js#647

@loganpowell
Copy link

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment