Skip to main content
Express + Typescript + Deno

Build a REST API with Express, TypeScript, and Deno

  • Tim Post

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:

  1. 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.

  2. 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.

Video Thumbnail 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:

Deno Permissions, "Deno Requesting Permissions"

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:

Deno Express Hello World, "Deno Saying Hello Through Express"

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:

Deno Config Showing Tasks, "Deno.jsonc in vscode"

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" assert { 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" assert { 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!