I recently have looked at a new password hashing library Argon2 and saw the following example in their announcement
1 | argon.generateSalt((err, salt) => { |
The library itself seems to be very good - it won the recent Password hashing competition, but this particular code is far from the best when using the library. Let me explain.
To better show the problems with this callback-based code, let me mock argon
object,
and create an actual "user" function around the salt generation and password hashing.
1 | var argon = { |
The success work-flow
If everything goes well, the code works. For example
1 | savePassword('test1234') |
Great.
Empty password example
Let us see how the code handles empty string as a password. Initially, everything looks ok - we do get an exception thrown.
1 | savePassword('') |
1 | $ node index.js |
We need to tell user that the password cannot be empty string. So let us catch the error and log it to the console.
1 | try { |
1 | $ node index.js |
Hmm, we did not catch the error! The error was thrown asynchronously inside a setTimeout
callback, and thus the try - catch
block around savePassword('')
never catches it.
How can we grab the errors?
Adding more callbacks
Let us pass a callback to savePassword
instead of wrapping the code in try - catch
block.
We are going to use it like this
1 | savePassword('', (err) => { |
We cannot use try - catch
inside savePassword
for the same reason, we could
not use it outside it. Thus we need to avoid throwing errors at all. Instead pass
the error into the callback function and return right away.
1 | function savePassword(password, cb) { |
Now the empty password leads to the meaningful error message
1 | $ node index.js |
Still, there is a subtle problem - we forgot to execute the callback on success. For example, a valid password will not show "success" message
1 | savePassword('test1234', (err) => { |
1 | $ node index.js |
Let us make sure to execute the callback on success
1 | function savePassword(password, cb) { |
1 | $ node index.js |
Making sure ALL success and error execution paths run the callback is very hard. Can we do better?
Promises to the rescue
Let us move away from callbacks to promises. We can still use the original
mock argon
object, but let us first make a couple of promise-returning functions.
1 | var promisify = require('bluebird').promisify |
The best feature for us is the clear error handling and success path.
Notice that we have NO error logic inside the savePassword
at all.
1 | function savePassword(password) { |
The caller code clearly defines separate path for successful save vs error
1 | savePassword('test1234') |
The errors are handled
1 | // savePassword('') |
Taking it to the next level
The above promise-based code is nice, yet it has several shortcomings, in my opinion
- The
getHash
function should be partially applied, since we know the first argumentpassword
right away!
1 | function savePassword(password) { |
Now we don't need the extra function just to pass salt
to the genHash
1 | function savePassword(password) { |
This point-free code is simpler - no extra variables to distract us!
Which brings us to my second pet peeve
- The promise chain has different types of the first and second links
Look at the value returned. What is the type of the genSalt()
and genHashFor
?
1 | return genSalt() // Promise instance |
This always bothers me - in order to combine promise steps together, I need to
execute the first promise-returning function, get a Promise
object and then
add all promise-returning functions!
If we have a Promise
object, that means the action has already started, which
means our promise chain cannot be created as easily wihout side effects, because
creating it means the action is already executing.
This is where the Tasks come in handy - they allow us to compose multiple actions (async or not) into a single pipeline and then start the first one.
Tasks to the rescue
A Task is just like a Promise, except the action
has to be started separately by calling .fork()
method. Let us "taskify" callback
returning functions instead of "promisifying" them. I wrote a tiny utility to convert
a Node style, callback expecting function into a function that returns a
data.Task instead. Let us apply it here
1 | var taskify = require('@bahmutov/taskify') |
By default, genSalt()
returns a Task, but nothing gets executed. In order to
actually run the function, we must call .fork
method
1 | const saltTask = genSalt() |
.fork()
expects two callbacks, just like a Promise
, but in reverse order -
the error callback is first, while success callback function is second. This is done
to make sure the errors are not silently ignored when there is no callback.
Tasks can be composed easily to run one after another using .chain
method.
To be explicit, we can write
1 | function savePassword(password) { |
Notice that we start with a Task (saltTask
instance) and then
chain new Tasks from functions passed to .chain
. Yet nothing runs until
someone calls .fork
!
1 | savePassword('test1234') |
The function savePassword
is so small and side effect free, that we can even make it a one-liner.
Our goal is to make salt => genHash(password, salt)
point-free. We can curry genHash
,
because we know it expects just two arguments.
1 | var R = require('ramda') |
Nice! The final code is very short, yet side effect free, and both success and error paths are available to avoid silent failures. This is a big improvement over code that uses callbacks.