If you’re writing an HTTP server in Node, you probably won’t use the standard library’s http.Server directly, instead you’ll use one of the many libraries that wrap it, like:

All of these libraries, except for Hapi and NestJS, use a CPS API for returning responses.

Which essentially means that the type of a route handler is:

type Handler = (req: Request, res: Response) => void

instead of what you typically see in other languages:

type Handler = (req: Request) => Response
// or
type Handler = (req: Request) => Promise<Response>

Mistakes caused by CPS

In these examples we’re using express’s API, but the other libraries are all pretty similar.

Let’s start with a basic hello world:

const express = require("express")
const app = express()

app.get("/", (req, res) => {
  res.send("hello world")
})

app.listen(3000, () => {
  console.log("started")
})

Easy enough, nothing of note really.

Now let’s fetch some data from the database:

const express = require("express")
const app = express()

async function getUsers(): Array<{ id: string; email: string }> {}

app.get("/", (req, res) => {
  return getUsers().then(users => res.status(200).send(users))
})

app.listen(3000, () => {
  console.log("started")
})

At first glance this looks fine, it passes the type checker and if you send the server a request, you’ll get the response you’re expecting, but there is a problem. If getUsers throws an error, there isn’t anything to catch the unhandled exception and return a 500.

To developers unfamiliar with CPS, this example looks like any errors will be handled by express since we’re returning a Promise from the handler, but that isn’t the case, instead the incoming request will hang.

We really shouldn’t be returning anything from the handler, and the express route handler is typed as returning void so why are we able to return the Promise without TypeScript complaining?

The TypeScript Connection

TypeScript is unique in that it allows functions not returning void, to be assigned to functions returning void.

There’s an open issue to add another flag to restrict this behavior, but for now we’re stuck with it.

To illustrate the problem, here’s a minimal example with an express like API:

type Req = { url: string }
type Res = {
  send: (data: string) => void
}

function createApp() {
  return {
    get: (path: string, cb: (req: Req, res: Res) => void): void => {}
  }
}

const app = createApp()

app.get("/", (req, res) => {
  return "hello world"
})

For those unfamiliar with CPS, they might think this endpoint returns the string hello world, but as we’ve seen above, that’s not the case.

TypeScript

Running this in TypeScript we don’t any errors or warnings.

Flow

If we try it with Flow we get the following errors:

15:   return "hello world"
             ^ Cannot call `app.get` with function bound to `cb` because string [1] is incompatible with undefined [2] in the return value. [incompatible-call]
References:
15:   return "hello world"
             ^ [1]
8:     get: (path: string, cb: (req: Req, res: Res) => void): void => {}
                                                       ^ [2]

Python

And for kicks let’s see what mypy and pyright have to say:

from typing import Callable

def foo(cb: Callable[[], None]) -> None:
    ...

def bar() -> int:
    ...

foo(bar)

mypy:

 main.py:9:5: error: Argument 1 to "foo" has incompatible type "Callable[[], int]"; expected "Callable[[], None]"  [arg-type]

pyright:

~/project/main.py
  ~/project/main.py:9:5 - error: Argument of type "() -> int" cannot be assigned to parameter "cb" of type "() -> None" in function "foo"
    Type "() -> int" cannot be assigned to type "() -> None"
      Function return type "int" is incompatible with type "None"
        Cannot assign to "None" (reportGeneralTypeIssues)

So flow, mypy, and pyright complain about the types, but TypeScript doesn’t.

Conclusion

CPS HTTP servers don’t play well with Promises resulting in unintuitive behavior and TypeScript can offer little help.