TypeScript/JavaScript Asynchrony

JavaScript "Concurrency"

Today's livecode will be in the repository under oct17_ts_fetch. Get the code to follow along. There's also more examples in the repository than we can cover in a single class, so you might find it useful for reference as well.

On the surface, JavaScript has really convenient support for concurrency. Try these in the browser console:

console.log('1')
setTimeout(() => console.log('2'), 5000)
console.log('3')

But just under that surface, complexity lurks:

console.log('1')
setTimeout(() => console.log('2'), 5000)
console.log('3')
while(true) {}

What's happening here? JavaScript—a language built for the web—is a language whose design is deeply and unavoidably tangled with concurrency.

And yet, JavaScript itself (barring some modern extensions, which are best used for expensive tasks that would block important things like UI interactivity) is single threaded. We don't create a new thread to wait for a web request to finish. Instead, we create a callback, like we would for a button being clicked.

In principle, callbacks are called as soon as possible. But "as soon as possible" is complicated.

The Callback Queue

Because TypeScript is single threaded, it can't actually invoke a callback while it's running some other piece of code. It has to wait until that code finishes, and then it looks at its callback queue to see if it has callbacks waiting to be processed.

Every time a callback is registered (in the setTimeout example above, the 0-argument function that invokes console.log is a callback) it is added to the queue. Crucially, these calls will only ever take place if they're popped off the queue. And they're only ever removed when the code currently being executed is finished.

This will become extremely important when you start sending web requests from your frontend to your API server. Callbacks are not threads.

Repeating for emphasis

Callbacks are not threads.

Code Review Exercise

Let's look at some code and anticipate potential errors related to concurrency. (I've removed the types so that we can run this in the browser console.) What's the value that you expect to be printed by this code?

function sayHello(){
    let toReturn = 0
    setTimeout(() => {
        toReturn = 500
    }, 0)
    setTimeout(() => {
        toReturn = 100
    }, 5000)
    return toReturn
}
console.log(sayHello())

Fetching Data in TypeScript

In TypeScript, you can use the fetch function to send a web request:

export function printGridInfo() {    
    const lat: number = 39.7456
    const lon: number = -97.0892
    const url: string = `https://api.weather.gov/points/${lat},${lon}`
    console.log(`Requesting: ${url}`)

    /* 
      Try #1
    */
    const json = fetch(`https://api.weather.gov/points/${lat},${lon}`)
    console.log(json)

Thinking about what we just learned about concurrency, and what we know about TypeScript, do you expect fetch to be synchronous or asynchronous? That is, will it "block" execution until it finishes?

If it's synchronous, then the page-load process might be delayed noticably. And slowing down page loading to the point a user notices is a cardinal sin on the web: it's "in the folklore" that a small delay can lead to a drop in revenue.

But if it's asynchronous (i.e., doesn't block) then what will be printed? Hopefully not undefined or null—the data will almost certainly get here eventually. So fetch returns a datatype whose entire purpose is to represent data that doesn't yet exist: a promise.

A promise can either be resolved, in which case the value exists within (but the value is still a promise object, not the data!) or rejected, in which case the promise contains an error. Until either of those events occurs, the promise exists in a state of potential only.

Aside: Many modern languages have promise libraries, and promises are a common way to manage asynchronous computation (like web requests). This is not just about TypeScript/JavaScript.

But because of how JavaScript works, the promise cannot be resolved until the current code finishes running. That is, the console.log statement can't print the right answer until after the console.log executes, because the right answer won't exist until then.

But how does console.log work, then?

Because the browser has multiple threads. It's only JavaScript evaluation that is single threaded. So we can see the update in the browser console before the currently-running JavaScript finishes.

Clearly, we need something to help make this work.

Extracting Promised Data with Callbacks

Promises can be given callback functions to run when they're resolved:

// Make a web request...
fetch(url) 
    // ...and when the response arrives, print it to the console
    .then(response => console.log(response)) 

The function passed to the then call will execute once a real value exists for the response. The .json() method returns a promise itself, so we need to provide a callback for that, too, now:

fetch(url)
    .then(response => response.json()) 
    .then(responseObject => {         
           console.log(responseObject)         
    }) 

This is called a chain of promises. Once the response is received, we convert it to an object. Once that conversion process is done, we print the result.

You can find more examples like this in the livecode repository. We'll also talk more about them in the gearup for this upcoming sprint.

What about types?

Promise<T> is a generic type in TypeScript. By default, fetch returns a Promise<any>---beware, here. The any type exists, at least in part, for interoperability with JavaScript, and it disables many checks involving computation "downstream" of the any value.

See the livecode for more content. In particular, there's:

  • a demo that reinforces how promises are not threads;
  • a demo of some pitfalls when using async/await, if you choose to do so;
  • a more complete series of attempts to extract Json from a fetched response, including how to make narrowing easy.

We'll cover what we can in today's class session, but please read over the livecode too. I leave comments to try and make the livecode a good supplemental resource.

What about async and await?

TypeScript provides two constructs that can often make working with promises easier: async (which tells the system that the function actually returns a promise, even if as written it returns a value), and await (which tells the system to invisibly inject callbacks as needed to act as though it is waiting for a certain operation to finish). You'll have already seen these used heavily in Playwright testing, because await is very convenient to, well await the loading of a webpage.

The key is remembering that await can only be used within an async function, and an async function always returns a promise. So if I write something like:

const f = async (url) => {
    const response = await fetch(url)
    return response;
}

then the return type of f is actually Promise<Response>, not Response. You can confirm this via mouseover in VSCode.

A screenshot of VSCode showing the above code, with a mouseover indicating the return type is a promise.

As a consequence, if you use async and await, you end up either:

  • putting all the pertinent functionality you care about in async functions after awaits, and thus can totally ignore the return value; or
  • if you need to do something with the final return value outside an async context, use .then() on the return value—which, again, will be a promise outside an async context.