DEV Community

JavaScript Joel
JavaScript Joel

Posted on • Updated on

setTimeout is a callback-style function. What would happen if we change that?

Clock gold timepiece

Today it is common practice to transform node-style-callback functions into promise-style functions. So why haven't we done this for setTimeout?

The main reason to prefer a promise-style function over a node-style-callback is to avoid Callback Hell.

Ryo fireballing code in callback hell

Nobody wants to see that.

After looking at setTimeout (and it's siblings setInterval or setImmediate), I can clearly see that it's a callback-style function.

setTimeout(callback, 1000);
//         --------
//                 \
//                   See that? Right there. A callback!
Enter fullscreen mode Exit fullscreen mode

Yet, it's so incredibly rare to see anyone convert setTimeout from callback to a promise. How has setTimeout flown under the radar? Is setTimeout different enough to get a pass?

I say no.

Node-style-callback functions

setTimeout may have been passed over because even though It's clearly a callback-style function, it is not a node-style-callback function, which is a little different.

First, let's have a look at node-style-callbacks to better see the difference. fs.readFile is a great example of a node-style-callback function.

fs.readFile(path[, options], callback)
//                           --------
//                          /
//    callback must be last
Enter fullscreen mode Exit fullscreen mode

And the callback itself must look like this:

const callback = (err, data) => { /* ... */ }
//                ---  ----
//               /          \
//    error first             data last
Enter fullscreen mode Exit fullscreen mode

If setTimeout was a traditional node-style-callback function, it could be easily converted with node's util.promisify. Here's an example of how easy it is to use util.promisify to convert fs.readFile into a promise-style function.

import fs from 'fs'
import { promisify } from 'util'

const readFile = promisify(fs.readFile)
Enter fullscreen mode Exit fullscreen mode

Unfortunately, util.promisify will not work. First, because the callback is not the last argument. Second, because the callback does not follow the (err, data) interface.

Promisifying setTimeout

Fortunately, transforming this manually is just as simple. I'll call the new function sleep.

const sleep = milliseconds => value => new Promise (resolve =>
  setTimeout(() => resolve(value), milliseconds)
)
Enter fullscreen mode Exit fullscreen mode

A few key things I would like to point out, regarding this code.

  • sleep is curried. You'll see why later.
  • sleep takes a value and then resolves the value. Again, you'll see why later.

Using sleep

Adding a pause into your code is now as simple as using a promise.

const log => msg => console.log(msg)

sleep(1000)('Hello World').then(log)
Enter fullscreen mode Exit fullscreen mode

That's fine, but not the reason why I am writing this.

What really excites me about sleep is the ability to slip it into the middle of promise chains.

In this example, it was trivial to add a 1 second delay between API calls.

import axios from 'axios'
import sleep from 'mojiscript/threading/sleep'

const fetchJson = url => axios.get(url).then(response => response.data)
const log = msg => (console.log(msg), msg)
//                                  -
//                                 /
//     comma operator. google it.

fetchJson('https://swapi.co/api/people/1')
  .then(log)
  .then(sleep(1000))
  .then(() => fetchJson('https://swapi.co/api/people/2'))
  .then(log)
  .then(sleep(1000))
  .then(() => fetchJson('https://swapi.co/api/people/3'))
  .then(log)
Enter fullscreen mode Exit fullscreen mode

Because sleep takes a value as input and then returns the same value, it will pass the value through to the next promise. sleep basically becomes Promise chain middleware.

Let's see this written in async/await style:

import axios from 'axios'
import sleep from 'mojiscript/threading/sleep'

const fetchJson = url => axios.get(url).then(response => response.data)
const log = msg => (console.log(msg), msg)

const main = async () => {
  const people1 = await fetchJson('https://swapi.co/api/people/1')
  log(people1)
  await sleep(1000)
  const people2 = await fetchJson('https://swapi.co/api/people/2')
  log(people2)
  await sleep(1000)
  const people3 = await fetchJson('https://swapi.co/api/people/3')
  log(people3)
}

main()
Enter fullscreen mode Exit fullscreen mode

Now to be honest, I like the problem sleep solves, but I'm not quite in love with the syntax of either of those codes I just demonstrated. Between these two examples, I actually think the async/await syntax is the worse. await is sprinkled all over the place and it's easy too easy to make a mistake.

Asynchronous Function Composition

Function composition is powerful and will probably take reading many articles to fully understand. Not just the how, but the why. If you want to start, I would recommend starting here: Functional JavaScript: Function Composition For Every Day Use .

I'm intentionally not explaining function composition in this article. I believe the syntax I am about to show you is so simple that you do not need to understand function composition at all.

import axios from 'axios'
import pipe from 'mojiscript/core/pipe'
import sleep from 'mojiscript/threading/sleep'

const fetchJson = url => axios.get(url).then(response => response.data)
const log = msg => (console.log(msg), msg)

const main = pipe ([
  () => fetchJson('https://swapi.co/api/people/1'),
  log,
  sleep(1000),
  () => fetchJson('https://swapi.co/api/people/2'),
  log,
  sleep(1000),
  () => fetchJson('https://swapi.co/api/people/3'),
  log
])

main()
Enter fullscreen mode Exit fullscreen mode

Damn. That is some good looking code!

But since we're already talking about function composition, it would be easy to extract fetchJson, log, sleep into it's own pipe and make the code a little more DRY.

import axios from 'axios'
import pipe from 'mojiscript/core/pipe'
import sleep from 'mojiscript/threading/sleep'

const fetchJson = url => axios.get(url).then(response => response.data)
const log = msg => (console.log(msg), msg)

const fetchLogWait = pipe ([
  id => fetchJson (`https://swapi.co/api/people/${id}`),
  log,
  sleep(1000)
])

const main = pipe ([
  () => fetchLogWait (1),
  () => fetchLogWait (2),
  () => fetchLogWait (3)
])

main()
Enter fullscreen mode Exit fullscreen mode

Asynchronous map

MojiScript also has the unique ability to asynchronously map. (Expect an entire article on this in the near future).

Async map is why I decided to write these examples using MojiScript's pipe instead of Ramda's pipeP. Up to this point, the examples will also work just fine with Ramda's pipeP. From this point on, the examples are MojiScript exclusive.

Let's see some code! How easy it is to asynchronously map the ajax calls?

const main = pipe ([
  ({ start, end }) => range (start) (end + 1),
  map (fetchLogWait),
])

main ({ start: 1, end: 3 })
Enter fullscreen mode Exit fullscreen mode

Pretty damn easy!

The text

All together in one runnable code block:

import axios from 'axios'
import log from 'mojiscript/console/log'
import pipe from 'mojiscript/core/pipe'
import map from 'mojiscript/list/map'
import range from 'mojiscript/list/range'
import sleep from 'mojiscript/threading/sleep'

const fetchJson = pipe ([
  axios.get,
  response => response.data
]) 

const fetchLogWait = pipe ([
  id => fetchJson (`https://swapi.co/api/people/${id}`),
  log,
  sleep (1000)
])

const main = pipe ([
  ({ start, end }) => range (start) (end + 1),
  map(fetchLogWait),
])

main ({ start: 1, end: 3 })
Enter fullscreen mode Exit fullscreen mode

Now this code is about as DRY as it gets!

setTimeout in a for loop

Now if you haven't seen this problem yet, it's given during a lot of JavaScript interviews. The code doesn't run as expected. What is the output?

for (var i = 1; i < 6; i++) {
  setTimeout(() => console.log(i), 1000)
}
Enter fullscreen mode Exit fullscreen mode

If you didn't guess it pauses for 1 second and then prints five 6's all at once, then you would be wrong.

The same program written using pipe and MojiScript's map. Except this one works as expected, printing the numbers 1 through 5 with a 1 second pause before each output.

const sleepThenLog = pipe ([
  sleep (1000),
  log
])

const main = pipe ([
  range (1) (6),
  map (sleepThenLog)
])
Enter fullscreen mode Exit fullscreen mode

Want to play more? Getting started with MojiScript: FizzBuzz

Things to google

Summary

Converting sleep into a promise-style function provides additional options to how async code is run.

Ramda's pipeP or MojiScript's pipe can sometimes be cleaner than Promises or async/await.

Asynchronous map is powerful.

One caveat, pointed out below, is this implementation does not allow for cancellation. So if you need to clearTimeout, you will need to modify this function.

My articles are very Functional JavaScript heavy, if you need more, follow me here, or on Twitter @joelnet!

Read my other articles:

Why async code is so damn confusing (and a how to make it easy)

How I rediscovered my love for JavaScript after throwing 90% of it in the trash

Cheers!

Top comments (4)

Collapse
 
maxart2501 profile image
Massimo Artizzu • Edited

Ok, that's so cool but... what about clearTimeout?

Because I'm sure many have thought about transforming setTimeout into a promise (e.g. was common in AngularJS), but there's always a catch.
There has been a debate about cancelable promises a couple of years ago, we got nothing it out of it πŸ˜•

Collapse
 
joelnet profile image
JavaScript Joel

This is a fantastic question. Without cancellable promises, you would not be able to cancel the setTimeout.

For a majority of use cases cancelling a promise is not needed. Though that doesn't mean it is not a valid use case. Throttling and debouncing is an example of a valid use case when you would want to cancel a promise.

The sleep implementation I created currently does not support cancellation. I have created an issue for this here: github.com/joelnet/MojiScript/issu.... This is a feature that would be great to have.

Here's a very naive implementation that could support cancellation:

const sleep = milliseconds => value => {
  let timeoutId
  const cancel = () => clearTimeout(timeoutId)
  const promise = new Promise(resolve => {
    timeoutId = setTimeout(() => resolve(value), milliseconds)
  })
  return Object.assign(promise, { cancel })
}

(async () => {
  // create promise and cancel
  const promise = sleep (1000) ('Uh oh!')
  const { cancel } = promise

  // promise to log when resolved
  promise.then(console.log)

  // cancel promise
  cancel()

  console.log('Done.')
})()

Bluebird also supports promise cancellation: bluebirdjs.com/docs/api/cancellati...

Converting this Promise to use bluebird's Promise would also work.

Collapse
 
maxart2501 profile image
Massimo Artizzu

Yes, that could work! πŸ™Œ

You can also consider the approach that's been taken for cancelling a fetch promise: it's based on a "signal" that's being created by an AbortController object:

const ctrl = new AbortController()
const signal = ctrl.signal
fetch(someUrl, { signal })
  .catch(error => {
    console.log(error.name)
  })

// Later on...
ctrl.abort()
// Then it logs 'AbortError'

You can extend your sleep function to accept a signal option and listen to an abort event. Although it does seem a little overkill for something like this...

Thread Thread
 
joelnet profile image
JavaScript Joel

This is interesting. I'll have to create some test project with the .cancel and an AbortController. Maybe I'll create a debounce to compare the differences.

Definitely something to think about.