Build a REST API with Express, TypeScript, and Deno
Thereâs a lot of really great tutorials for anyone looking to get started building a REST API with TypeScript and Express. These tutorials, as great as they are, have two downsides:
They require you to install and configure TypeScript, and ship everything required to accomplish that. This can be time-consuming and a source of frustration, particularly for new developers.
They donât address the need to be proactive about boxing in untrusted code; this isnât surprising, as the majority of tooling doesnât support it.
Which is why we have created this tutorial. With Deno, you don’t need to configure TypeScript, so you can get up and running with minimal dependencies.
Feel free to watch the video walkthrough of this post.
If you want to skip to the code, you can do so here.
Setting Up Express and its Types
Let’s create main.ts
, which will contain the logic for our API.
In this file, let’s import Express through an npm
specifier.
import express, { NextFunction, Request, Response } from "npm:express@4.18.2";
This gets us express, but not the type definitions. Let’s import the type definitions by adding this comment:
// @deno-types="npm:@types/express@4"
import express, { NextFunction, Request, Response } from "npm:express@4.18.2";
Next, we need to define a way to interact with Expressâ application interface, and weâre going to need to define a port for it to run which weâll get from the environment:
const app = express();
const port = Number(Deno.env.get("PORT")) || 3000;
Letâs define a test route that will say hello upon receiving a GET request, which weâll just make the default base route for now:
app.get("/", (_req, res) => {
res.status(200).send("Hello from Deno and Express!");
});
Now weâve built the simple logic, we just need it to listen and start serving
requests! For this weâll use .listen()
as shown:
app.listen(port, () => {
console.log(`Listening on ${port} ...`);
});
And now weâre ready to go!
Starting the Server Securely
Letâs start our server:
We have to pull in all kinds of code when weâre developing APIs, from geo information, AI, ad servers and whatever other inputs have to come together to produce whatâs required. Sure, we donât anticipate Express introducing vulnerabilities, but Express is just one part of the stack youâre going to need to make something.
Had it requested access to system information, high resolution timers, or access outside of the directory, it would have been a red flag. You can specify permissions in a variety of ways, including the shebang in scripts.
At this point, we have a running API service that we can query with curl:
Weâre now certain the framework is working correctly, so weâre sure about our
installation and everything else. But, itâs not really a great working
environment yet, so letâs set up our deno.jsonc
file to define some helper
scripts:
This works similar to package.json
scripts (in fact, Deno can even use
package.json
scripts, but a deno.jsonc
is recommended), where we have one
task for development, and another to start the server without watching and
reloading on changes.
Seeing the output of deno task
, we can confirm that we have two scripts
available:
$ deno task
Available tasks:
- dev
deno run --allow-read --allow-net --allow-env --watch main.ts
- start
deno run --allow-read --allow-net --allow-env main.ts
We can use deno task dev
and deno task start
, respectively.
Adding Logging
The next thing weâre going to need is some kind of logging functionality so that we can troubleshoot our requests while weâre building them, and thatâs a great introduction into the concept of middleware in Express.
Middleware is a function that can read and even modify the req
and res
objects. We use middleware to do everything from logging to injecting headers,
or even rate limiting and checking auth. Middleware must do one of two things
when itâs done:
- It must close the connection with a response if appropriate, or
- It must call
next()
, which tells Express that itâs time to pass the objects to the next middleware function
Middleware takes 3 arguments: req
and res
as youâd expect, and also next
,
which points to the next appropriate middleware function (or returns control to
the handler function).
Rather than console.log()
something in every handler that we write, letâs
define the first middleware function to be a logger, and tell Express that weâd
like to use it. In main.ts
:
const reqLogger = function (req, _res, next) {
console.info(`${req.method} request to "${req.url}" by ${req.hostname}`);
next();
};
You can have as much middleware as you want, and organize it in a way that works for you. Just remember that the speed of your responses depends on how quickly your middleware chain hands control back to the framework. Middleware is executed in the order that the framework is informed of it.
Generating Data
So weâre now in a pretty great place to start developing. Run the
./generate_data.ts
command (deno run -A ./generate_data.ts
if the shebang
wonât work for you), which will generate some mock user data in data_blob.json
that we can safely use like any other read-only data store safely through Denoâs
import type assertions:
import demoData from "./data_blob.json" with { type: "json" };
We now have access to demoData.users
in our handlers, so letâs write two
handlers:
- one
/users
which returns the entire contents of the users object, and - an additional dynamic route that allows us to look up a single user by ID
app.get("/users", (_req, res) => {
res.status(200).json(demoData.users);
});
app.get("/users/:id", (req, res) => {
const idx = Number(req.params.id);
for (const user of demoData.users) {
if (user.id === idx) {
res.status(200).json(user);
}
}
res.status(400).json({ msg: "User not found" });
});
We can also clear away the hello world default route, which leaves us with a nice API starting point:
// @deno-types="npm:@types/express@4"
import express, { NextFunction, Request, Response } from "npm:express@4.18.2";
import demoData from "./data_blob.json" with { type: "json" };
const app = express();
const port = Number(Deno.env.get("PORT")) || 3000;
const reqLogger = function (req, _res, next) {
console.info(`${req.method} request to "${req.url}" by ${req.hostname}`);
next();
};
app.use(reqLogger);
app.get("/users", (_req, res) => {
res.status(200).json(demoData.users);
});
app.get("/users/:id", (req, res) => {
const idx = Number(req.params.id);
for (const user of demoData.users) {
if (user.id === idx) {
res.status(200).json(user);
}
}
res.status(400).json({ msg: "User not found" });
});
app.listen(port, () => {
console.log(`Listening on ${port} ...`);
});
Note the Hello, world!
handler on /
has been removed (and isn’t present in
the linked repository).
What’s next?
We have a great starting point for a REST API in under 30 lines of code. Now,
you can add a POST
handler using app.post()
, PUT handler with app.put()
,
or whatever other methods you want.
In a future post, we’ll cover how we can use Deno’s test runner and benchmark tools so weâre more comfortable taking our code from a proof of concept to something weâll trust in production. We’ll end the series after that with ways that we can deploy our project.
Stuck? Get help in our Discord!