What Node.js' HTTP server gets wrong
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 Promise
s resulting in unintuitive
behavior and TypeScript can offer little help.