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
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
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
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 withts-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
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
Now in our Typescript config file, let's set some compiler options.
-
module: "commonjs"
—when we compile our code, our output will usecommonjs
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
}
}
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"
}
}
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
And the file contents:
node_modules
build
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
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}`);
});
Now in our terminal we can start the app by using yarn run start
:
yarn run start
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
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!
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
Now add the User
type in types.ts
and make sure to export it:
export type User = { username: string; password: string };
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
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[] = [];
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
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}`);
});
Finally, we can create a POST
request handler on a /users
endpoint. This handler will do a few things:
- Check if both a
username
andpassword
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);
};
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}`);
});
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
You should get the following response back:
User created
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
And we get the following response:
Bad username or password
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
);
};
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}`);
});
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
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,
};
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;
};
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();
}
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;
...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;
};
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;
};
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
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');
};
Next up, let's add an auth handler that contains our login
function.
touch handlers/auth.ts
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');
};
Finally, we'll add one more handler for our home route ("Hello world").
touch handlers/home.ts
And this one is pretty simple:
import { Handler } from '../types';
export const home: Handler = (req, res) => {
res.send('Hello world');
};
Middleware
We don't have any custom middleware yet, but let's change that! First, add a directory for our middleware:
mkdir middleware
We can add a middleware that will log the path
that the client hit. We can call this requestLogger.ts
:
touch middleware/requestLogger.ts
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();
};
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
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,
},
];
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}`);
});
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)