Using Typescript with hapi

I've been using hapi lately, and decided to start using Typescript at the same time. When I looked though there didn't seem to be a lot out there on using them both together. Here's what I learned.

I've been using hapi lately, and decided to start using Typescript at the same time. When I looked though there didn't seem to be a lot out there on using them both together. Here's what I learned.

I'm going to assume you have some degree of familiarity with Javascript, along with a basic understanding of what Typescript is.

If that's not the case then I can would definitely recommend reading the MDN JavaScript tutorials, followed by the 5 minute Typescript introduction.

I've chosen to use yarn in the examples below; if you're using npm instead, just change yarn add to npm install.

I've included what you need to get the system up and running, and tried to explain as we go, but this post isn't going to go in depth into any particular point. I've tried to include relevant links as we go, but if I've missed something out then definitely let me know and I'll try to cover it further.

Creating the initial server

Admin stuff

First thing - create a project directory and package.json file. Then install hapi for production, since we know we're going to need that.

$ mkdir hapi_typescript
$ cd hapi_typescript
$ yarn init -y
$ yarn add @hapi/hapi

We're also going to need a few more packages in development. Here we're adding the typescript system itself, along with type definitions for hapi and node.

Without installing those, Typescript doesn't know any of the type details for Node or for hapi, so it will complain at something which isn't actually a problem.

We want to be able to see the actual problems, as well as benefit from type-safety using Node and hapi. The type definitions in this post are all coming from DefinitelyTyped.

$ yarn add -D typescript @types/hapi__hapi @types/node
$ yarn add -D nodemon npm-run-all

Next, we have to create a tsconfig.json file with the Typescript options. The easiest thing to do is let tsc do that for us. (tsc is the Typescript compiler executable.)

$ npx tsc --init

For this basic system we're only going to adjust two options - outDir (where to put the compiled Javascript) and rootDir (where the source code lives):

    "outDir": "./lib",
    "rootDir": "./src",

And the final bit of admin - we'll add these scripts to the package.jsonfile, to make it easy to develop.

  • dev:tsc starts the compiler in watch mode, meaning it watches for any changes and automatically rebuilds.
  • dev:serve uses nodemon to automatically reload the server when the Javascript changes.
  • dev uses npm-run-all to run both commands at the same time, so you don't have to have two terminals open.
    "scripts": {
        "dev:tsc": "tsc --watch -p .",
        "dev:serve": "nodemon -e js -w lib lib/main.js",
        "dev": "run-p dev:*"
    }

Lastly, we can change the line in package.json which tells node which file to execute. The scripts don't use it, but it's good practice.

"main": "lib/main.js"

Code!

Right - now we can finally do some coding! We’re going to separate the file containing the server setup code from the file which actually starts it up. It’ll pay off when we start adding tests.

Notice the type declarations for the variables, and the return types on the functions. If you’re going to use Typescript, you might as well benefit from the compiler catching type mistakes.

'use strict';

import Hapi from "@hapi/hapi";
import { Server } from "@hapi/hapi";

export let server: Server;

export const init = async function(): Promise<Server> {
    server = Hapi.server({
        port: process.env.PORT || 4000,
        host: '0.0.0.0'
    });

    // Routes will go here

    return server;
};

export const start = async function (): Promise<void> {
    console.log(`Listening on ${server.settings.host}:${server.settings.port}`);
    return server.start();
};

process.on('unhandledRejection', (err) => {
    console.error("unhandledRejection");
    console.error(err);
    process.exit(1);
});

main.ts:

import { init, start } from "./server";

init().then(() => start());

Giving it a spin

Rather than using yarn dev here, let’s try the stages individually.

$ yarn dev:tsc
[19:54:59] Starting compilation in watch mode...

[19:55:00] Found 0 errors. Watching for file changes.

Well, that looks promising. Ctrl-C out of that and start the server:

$ yarn dev:serve
yarn run v1.22.10
$ nodemon -e js -w lib lib/main.js
[nodemon] 2.0.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): lib/**/*
[nodemon] watching extensions: js
[nodemon] starting `node lib/main.js`
Starting server, listening on 0.0.0.0:4000

It’s alive! It’s alive!

Next step

It’s alive, but it doesn't exactly do much. Let's listen for a request and send a reply.

Tests

Since we’re adding functionality, let’s add a test. (Tests are good for the usual reasons; this isn't the place to convince you you need them.)

Setup

We're going to keep our tests in test. We'll use chai and mocha to run them; since we're using Typescript we'll also want to add the relevant type annotations from DefinitelyTyped.

$ mkdir test
$ yarn add -D chai mocha @types/chai @types/mocha

Lastly, we'll use ts-node to directly run these, rather than compiling them to JavaScript.

$ yarn add -D ts-node

We’ll need to tweak tsconfig.json slightly, to tell tsc not to try building the tests. Add this after the end of the compilerOptions object.

"exclude": [
    "test"
]

For the final bit of admin, we’ll add this script to package.json to make it easy to run the tests.

"test": "NODE_ENV=test mocha -r ts-node/register test/**/*.test.ts"

Actual test code!

Finally, time to add our first test!

test/index.test.ts:

import { Server } from "@hapi/hapi";
import { describe, it, beforeEach, afterEach } from "mocha";
import chai, { expect } from "chai";

import { init } from "../src/server";

describe("smoke test", async () => {
    let server: Server;

    beforeEach((done) => {
        init().then(s => { server = s; done(); });
    })
    afterEach((done) => {
        server.stop().then(() => done());
    });

    it("index responds", async () => {
        const res = await server.inject({
            method: "get",
            url: "/"
        });
        expect(res.statusCode).to.equal(200);
        expect(res.result).to.equal("Hello! Nice to have met you.");
    });
})

First thing is to import the various types we’re going to use. Then we import the init function from the server.ts file.

The beforeEach function creates a clean server object before each test, which the afterEach function cleans up. Separating the server code pays off now - we can initialise the server for use in our tests without actually having the overhead of starting it up.

The test itself makes use of the hapi inject method, calling into the server code without actually having to code to the effort of making an HTTP call. Here we’re hitting the / route; you can see the test expects the call to succeed, with a nice little message.

Of course, we haven’t written that code yet, so if we run it…

  0 passing (37ms)
  1 failing

  1) smoke test
       index responds:

      AssertionError: expected 404 to equal 200
      + expected - actual

      -404
      +200

      at hapi-typescript/test/index.test.ts:22:35
      [trace continues]

As expected, it fails. Let’s fix that.

Server code

We’re back in server.ts now. First thing is to modify the type imports at the top of the file, to include the Request type:

import { Request, Server } from "@hapi/hapi";

We’ll need that for the index function which does the actual replying for us. In this case it simply returns a string, so we’ve declared that as the return type. Declaring the type of request makes sure we don’t use any properties or methods which aren’t present on that object.

function index(request: Request): string {
    console.log("Processing request", request.info.id);
    return "Hello! Nice to have met you.";
}

Lastly, we need to connect up the route in the init() function, where the comment in the earlier code indicates.

    server.route({
        method: "GET",
        path: "/",
        handler: index
    });

Now if we run the tests…

  smoke test
    ✓ index responds


  1 passing (33ms)

✨  Done in 2.25s.

Hurrah! Happiness ensues!

Well, that's one route defined in the same file. The next post will import routes from another file (with appropriate tests), but I think this post is long enough for now!

Subscribe to Paul Walker

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe