DEV Community

Cover image for Your First Node Express App with Typescript
Nick Scialli (he/him)
Nick Scialli (he/him)

Posted on • Originally published at typeofnan.dev

Your First Node Express App with Typescript

Express is the most ubiquitous framework for nodejs. In this post, we learn how to add Typescript to the mix.

The Goal

Our goal here is to be able to use Typescript to develop our application quickly, but ultimately we want our application to compile down to plain old javascript to be executed by the nodejs runtime.

Initial Setup

First and foremost, we'll want to create an application directory in which we host our app files. We'll call this directory express-typescript-app:

mkdir express-typescript-app
cd express-typescript-app
Enter fullscreen mode Exit fullscreen mode

To accomplish our goal, we'll want to make a distinction between what we install as regular application dependencies versus development dependencies (i.e., dependencies that will help us develop our application but that won't be necessary after we compile our code).

Throughout this tutorial, I'll be using yarn as the package manager, but you could use npm just as easily!

Production Dependencies

In production, this will still be an express app. Therefore, we'll need to install express!

yarn add express
Enter fullscreen mode Exit fullscreen mode

Note that this will create a package.json file for us!

For now, this will be our only production dependency (we'll add another later).

Development Dependencies

In development, we'll be writing Typescript. Therefore, we need to install typescript. We'll also want to install the types for both express and node. We use the -D flag to let yarn know that these are dev dependencies.

yarn add -D typescript @types/express @types/express @types/node
Enter fullscreen mode Exit fullscreen mode

Great! But we're not quite done. Sure, we could stop here, but the problem is that we would need to compile our code every time we wanted to see changes in development. That's no fun! So we'll add a couple additional dependences:

  • ts-node—this package will let us run Typescript without having to compile it! Crucial for local development.
  • nodemon—this package automagically watches for changes in your application code and will restart your dev server. Coupled with ts-node, nodemon will enable us to see changes reflected in our app instantaneously!

Again, these are development dependencies because they only help us with development and won't be used after our code is compiled for production.

yarn add -D ts-node nodemon
Enter fullscreen mode Exit fullscreen mode

Configuring our App to Run

Configuring Typescript

Since we're using Typescript, let's set some Typescript options. We can do this in a tsconfig.json file.

touch tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Now in our Typescript config file, let's set some compiler options.

  • module: "commonjs"—when we compile our code, our output will use commonjs modules, which we're familiar with if we've used node before.
  • esModuleInterop: true—this option allows us to do star (*) and default imports.
  • target: "es6"—unlike on the front-end, we have control of our runtime environment. We will make sure we use a version of node that understands the ES6 standard.
  • rootDir: "./"—the root directory for our Typescript code is the current directory.
  • outDir: "./build"—when we compile our Typescript to JavaScript, we'll put our JS in the ./build directory.
  • strict: true—enables strict type-checking!

All together, our tsconfig.json file should look like this:

{
  "compilerOptions": {
    "module": "commonjs",
    "esModuleInterop": true,
    "target": "es6",
    "rootDir": "./",
    "outDir": "./build",
    "strict": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Configuring package.json Scripts

Currently, we have no package.json scripts! We'll want to add a couple scripts: one script to start the app in development mode and another script to build the application for production. To start the application in development mode, we just need to run nodemon index.ts. For building the application, we've given our Typescript compiler all the information it needs in the tsconfig.json file, so all we have to do is run tsc.

The following shows what your package.json file might look like at this point. Note that your dependencies will likely be at different versions than mine since I wrote this at some point in the past (hello from the past, by the way).

{
  "dependencies": {
    "express": "^4.17.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.11",
    "@types/node": "^14.14.21",
    "nodemon": "^2.0.7",
    "ts-node": "^9.1.1",
    "typescript": "^4.1.3"
  },
  "scripts": {
    "build": "tsc",
    "start": "nodemon index.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Git Config

If you're using git (I recommend it!), you'll want a .gitignore file to ignore your node_modules folder and your build folder:

touch .gitignore
Enter fullscreen mode Exit fullscreen mode

And the file contents:

node_modules
build
Enter fullscreen mode Exit fullscreen mode

Finished Setup!

I hope you've made it this far because we're done setup! It's not too bad, but definitely slightly more of a barrier to entry than a normal express.js application.

Creating our Express App

Let's create our express app. This is actually fairly similar to how we would do it with plain old JavaScript. The one difference is that we get to use ES6 imports!

Let's create index.ts:

touch index.ts
Enter fullscreen mode Exit fullscreen mode

And in the index.ts file, we can do a basic "hello world" example:

import express from 'express';

const app = express();
const PORT = 3000;

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Now in our terminal we can start the app by using yarn run start:

yarn run start
Enter fullscreen mode Exit fullscreen mode

And you'll get an output like this:

$ nodemon index.ts
[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: ts,json
[nodemon] starting `ts-node index.ts`
Express with Typescript! http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

We can see nodemon is watching all our files for changes and launches our app using ts-node index.ts. We can now navigate to http://localhost:3000 in a web browser and see our "hello world" app in all it's glory!

browser showing "hello world"

Huzzah! (well, it's a start!)

Beyond "Hello World"

Our "Hello world" app is a nice achievement, but I think we can do more. Let's create some (very bad) user registration functionality to flex our express/typescript muscles a bit. Specifically, this functionality will:

  • Maintain a list of users and associated passwords in memory
  • Have a POST endpoint that allows users to register (i.e., adds an additional user to the aforementioned list)
  • Have a POST endpoint that allows users to attempt to sign in, issuing an appropriate response based on the correctness of provided credentials

Let's get started!

Maintaining Users

First, let's create a types.ts file in which we can declare our User type. We'll end up using this file for more types in the future.

touch types.ts
Enter fullscreen mode Exit fullscreen mode

Now add the User type in types.ts and make sure to export it:

export type User = { username: string; password: string };
Enter fullscreen mode Exit fullscreen mode

Okay! So rather than using a database or anything fancy like that, we're just going to maintain our users in memory. Let's create a users.ts file in a new directory, data.

mkdir data
touch data/users.ts
Enter fullscreen mode Exit fullscreen mode

Now in our users.ts file, we can create an empty array of users and make sure to specify it as an array of our User type.

import { User } from "../types.ts;

const users: User[] = [];
Enter fullscreen mode Exit fullscreen mode

POSTing New Users

Next, we'll want to be able to POST a new user to our application. If you're familiar with what an HTTP actually looks like, you know that variables will typically come across in the HTTP request body looking something like url encoded variables (e.g., username=foo&password=bar). Rather than parsing this ourselves, we can use the ubiquitous body-parser middleware. Let's install that now:

yarn add body-parser
Enter fullscreen mode Exit fullscreen mode

And then we'll import and use it in our app:

import express from 'express';
import bodyParser from 'body-parser';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Finally, we can create a POST request handler on a /users endpoint. This handler will do a few things:

  • Check if both a username and password are defined on the request body and run some very basic validations on those fields
  • Return a 400 status message if there is anything wrong with the provided values
  • Push a new user to our users array
  • Return a 201 status message

Let's get to it. First, we create an addUser function in our data/users.ts file:

import { User } from '../types.ts';

const users: User[] = [];

const addUser = (newUser: User) => {
  users.push(newUser);
};
Enter fullscreen mode Exit fullscreen mode

Now, we go back to our index.ts file and add the "/users" route:

import express from 'express';
import bodyParser from 'body-parser';
import { addUser } from './data/users';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send('Hello world');
});

app.post('/users', (req, res) => {
  const { username, password } = req.body;
  if (!username?.trim() || !password?.trim()) {
    return res.status(400).send('Bad username or password');
  }
  addUser({ username, password });
  res.status(201).send('User created');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Our logic here is simply that our username and password variables need to exist and, when using the trim() method, they need to be longer than zero characters. If those criteria fail, we return a 400 error with a custom Bad Request message. Otherwise, we push the new username and password onto our users array and send a 201 status back.

Note: You may notice that our array of users has no way of knowing if a username is added twice. Let's pretend our app doesn't have this glaring issue!

Let's take this signup logic for a test drive using curl! In your terminal, make the following POST request:

curl -d "username=foo&password=bar" -X POST http://localhost:3000/users
Enter fullscreen mode Exit fullscreen mode

You should get the following response back:

User created
Enter fullscreen mode Exit fullscreen mode

Success! Now, let's just verify that our request fails if we don't meet our validation criteria. We'll provide a password that's just one space character (" ".trim() is falsey so our validation will fail).

curl -d "username=foo&password= " -X POST http://localhost:3000/users
Enter fullscreen mode Exit fullscreen mode

And we get the following response:

Bad username or password
Enter fullscreen mode Exit fullscreen mode

Looking good to me!

Logging In

Logging in will be a very similar process. We'll grab the provided username and password from the request body, use the Array.find method to see if that username/password combination exists in our users array, and return either a 200 status to indicate the user is logged in or a 401 status to indicate that the user is not authenticated.

First, let's add a getUser function to our data/users.ts file:

import { User } from '../types';

const users: User[] = [];

export const addUser = (newUser: User) => {
  users.push(newUser);
};

export const getUser = (user: User) => {
  return users.find(
    (u) => u.username === user.username && u.password === user.password
  );
};
Enter fullscreen mode Exit fullscreen mode

This getUser function will either return the matching user from the users array or it will return undefined if no users match.

Next, we use this getUser function in our index.ts file:

import express from 'express';
import bodyParser from 'body-parser';
import { addUser, getUser } from "./data/users';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

app.get('/', (req, res) => {
  res.send('Hello word');
});

app.post('/users', (req, res) => {
  const { username, password } = req.body;
  if (!username?.trim() || !password?.trim()) {
    return res.status(400).send('Bad username or password');
  }
  addUser({ username, password });
  res.status(201).send('User created');
});

app.post('/login', (req, res) => {
  const { username, password } = req.body;
  const found = getUser({username, password})
  if (!found) {
    return res.status(401).send('Login failed');
  }
  res.status(200).send('Success');
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

And now we can once again use curl to add a user, log in as that user, and then also fail a login attempt:

curl -d "username=joe&password=hard2guess" -X POST http://localhost:3000/users
# User created

curl -d "username=joe&password=hard2guess" -X POST http://localhost:3000/login
# Success

curl -d "username=joe&password=wrong" -X POST http://localhost:3000/login
# Login failed
Enter fullscreen mode Exit fullscreen mode

Hey, we did it!

Exporing Express Types

You may have noticed that everything we have done so far, outside of our initial setup, is basic express stuff. In fact, if you have used express a bunch before, you're probably bored (sorry).

But now we'll get a bit more interesting: we're going to explore some of the types exported by express. To do so, we will define a custom structure for defining our routes, their middleware, and handler functions.

A Custom Route Type

Perhaps we want to establish a standard in our dev shop where we write all our routes like this:

const route = {
  method: 'post',
  path: '/users',
  middleware: [middleware1, middleware2],
  handler: userSignup,
};
Enter fullscreen mode Exit fullscreen mode

We can do this by defining a Route type in our types.ts file. Importantly, we'll be making use of some important types exported from the express package: Request, Response, and NextFunction. The Request object represents the request coming from our client, the Response object is the response that express sends, and the NextFunction is the signature of the next() function you may be familiar with if you have used express middlware.

In our types.ts file, let's specify our Route. We'll make liberal use of the any type for our middleware array and handler function since we will want to discuss those further later.

export type User = { username: string; password: string };

type Method =
  | 'get'
  | 'head'
  | 'post'
  | 'put'
  | 'delete'
  | 'connect'
  | 'options'
  | 'trace'
  | 'patch';

export type Route = {
  method: Method;
  path: string;
  middleware: any[];
  handler: any;
};
Enter fullscreen mode Exit fullscreen mode

Now, if you're familiar with express middleware, you know that the a typical middleware function looks something like this:

function middleware(request, response, next) {
  // Do some logic with the request
  if (request.body.something === 'foo') {
    // Failed criteria, send forbidden resposne
    return response.status(403).send('Forbidden');
  }
  // Succeeded, go to the next middleware
  next();
}
Enter fullscreen mode Exit fullscreen mode

It turns out that express exports types for each of the three arguments that middlware take: Request, Response, and NextFunction. Therefore, we could create a Middleware type if we wanted to:

import { Request, Response, NextFunction } from 'express';

type Middleware = (req: Request, res: Response, next: NextFunction) => any;
Enter fullscreen mode Exit fullscreen mode

...but it turns out express has a type for this already called RequestHandler! I don't love the name RequestHandler for this type, so we're going to go ahead and import it under the name Middleware and add it to our Route type in types.ts:

import { RequestHandler as Middleware } from 'express';

export type User = { username: string; password: string };

type Method =
  | 'get'
  | 'head'
  | 'post'
  | 'put'
  | 'delete'
  | 'connect'
  | 'options'
  | 'trace'
  | 'patch';

export type Route = {
  method: Method;
  path: string;
  middleware: Middleware[];
  handler: any;
};
Enter fullscreen mode Exit fullscreen mode

Finally, we need to type our handler function. This is purely a personal preference since our handler could technically be our last middleware, but perhaps we have made a design decision that we want to single out our handler function. Importantly, we don't want our handler to take a next parameter; we want it to be the end of the line. Therefore, we will create our own Handler type. It will look very similar to RequestHandler but won't take a third argument.

import { Request, Response, RequestHandler as Middleware } from 'express';

export type User = { username: string; password: string };

type Method =
  | 'get'
  | 'head'
  | 'post'
  | 'put'
  | 'delete'
  | 'connect'
  | 'options'
  | 'trace'
  | 'patch';

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

export type Route = {
  method: Method;
  path: string;
  middleware: Middleware[];
  handler: Handler;
};
Enter fullscreen mode Exit fullscreen mode

Adding Some Structure

Instead of having all of our middleware and handlers in our index.ts file, let's add some structure.

Handlers

First, let's move our user-related handler functions into a handlers directory:

mkdir handlers
touch handlers/user.ts
Enter fullscreen mode Exit fullscreen mode

Then, within our handlers/user.ts file, we can add the follow code. This represents the one user-related route handler (signing up) that we already have in our index.ts file, we're just reorganizing. Importantly, we can be sure that the signup function meets our need because it matches the type signature of the Handler type.

import { addUser } from '../data/users';
import { Handler } from '../types';

export const signup: Handler = (req, res) => {
  const { username, password } = req.body;
  if (!username?.trim() || !password?.trim()) {
    return res.status(400).send('Bad username or password');
  }
  addUser({ username, password });
  res.status(201).send('User created');
};
Enter fullscreen mode Exit fullscreen mode

Next up, let's add an auth handler that contains our login function.

touch handlers/auth.ts
Enter fullscreen mode Exit fullscreen mode

Here's the code we can move to the auth.ts file:

import { getUser } from '../data/users';
import { Handler } from '../types';

export const login: Handler = (req, res) => {
  const { username, password } = req.body;
  const found = getUser({ username, password });
  if (!found) {
    return res.status(401).send('Login failed');
  }
  res.status(200).send('Success');
};
Enter fullscreen mode Exit fullscreen mode

Finally, we'll add one more handler for our home route ("Hello world").

touch handlers/home.ts
Enter fullscreen mode Exit fullscreen mode

And this one is pretty simple:

import { Handler } from '../types';

export const home: Handler = (req, res) => {
  res.send('Hello world');
};
Enter fullscreen mode Exit fullscreen mode

Middleware

We don't have any custom middleware yet, but let's change that! First, add a directory for our middleware:

mkdir middleware
Enter fullscreen mode Exit fullscreen mode

We can add a middleware that will log the path that the client hit. We can call this requestLogger.ts:

touch middleware/requestLogger.ts
Enter fullscreen mode Exit fullscreen mode

And in this file, we can once again import RequestHandler from express to make sure our middleware function is the right type:

import { RequestHandler as Middleware } from 'express';

export const requestLogger: Middleware = (req, res, next) => {
  console.log(req.path);
  next();
};
Enter fullscreen mode Exit fullscreen mode

Creating Routes

Now that we have our fancy new Route type and our handlers and middleware organized into their own spaces, let's write some routes! We'll create a routes.ts file at our root directory.

touch routes.ts
Enter fullscreen mode Exit fullscreen mode

And here's an example of what this file could look like. Note that I added our requestLogger middleware to just one of the routes to demonstrate how it might look—it otherwise doesn't make a whole lot of sense to log the request path for only one route!

import { login } from './handlers/auth';
import { home } from './handlers/home';
import { signup } from './handlers/user';
import { requestLogger } from './middleware/requestLogger';
import { Route } from './types';

export const routes: Route[] = [
  {
    method: 'get',
    path: '/',
    middleware: [],
    handler: home,
  },
  {
    method: 'post',
    path: '/users',
    middleware: [],
    handler: signup,
  },
  {
    method: 'post',
    path: '/login',
    middleware: [requestLogger],
    handler: login,
  },
];
Enter fullscreen mode Exit fullscreen mode

Revamping Our index.ts File

Now the payoff! We can greatly simplify our index.ts file. We replace all our route code with a simple forEach loop that uses everything we specified in routes.ts to register our routes with express. Importantly, the Typescript compiler is happy because our Route type fits the shape of the corresponding express types.

import express from 'express';
import bodyParser from 'body-parser';
import { routes } from './routes';

const app = express();
const PORT = 3000;

app.use(bodyParser.urlencoded({ extended: false }));

routes.forEach((route) => {
  const { method, path, middleware, handler } = route;
  app[method](path, ...middleware, handler);
});

app.listen(PORT, () => {
  console.log(`Express with Typescript! http://localhost:${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Wow this looks great! And, importantly, we have established a type-safe pattern by which we specify routes, middleware, and handlers.

The App Code

If you'd like to see the final app code, head on over to the github repository here.

Conclusion

Well, that was a fun exploration of express with Typescript! We see how, in its most basic form, it's not dissimlar to a typical express.js project. However, you can now use the awesome power of Typescript to give your project the structure you want in a very type-safe way.

Top comments (0)