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.json
file, 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
usesnodemon
to automatically reload the server when the Javascript changes. -
dev
usesnpm-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.
src/server.ts
:
'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);
});
src/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 go 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. See the next post to import routes from another file (with appropriate tests).
Top comments (0)