How to build a basic API with TypeScript, Koa, and TypeORM
This tutorial explores how to build a basic API using TypeScript, Koa, and TypeORM. You’ll need a Node.js version that supports await/async.
TypeScript is an open-source programming language that's increasingly popular within the software development community.
Developed and maintained by Microsoft, it reduces the amount of tests that software engineers like myself need to write, and it speeds up development by reporting errors as you type, thanks to speedy and intelligent VSCode integration.
This tutorial will show you some of the advantages of TypeScript by demonstrating how simple it is to implement and use. We’ll also explore how additional features, such as class decorators, can further speed-up your development.
To do this, we’re going to create a simple API that stores movie names, release years, and a numeric rating. The data will then be stored in PostgreSQL using TypeORM, a TypeScript-friendly data mapper.
This article is based on Node.js 10, but 8 will do just fine. You’ll also need a PostgreSQL installation available. We’ll document how to get it up and running via Docker, but if you don’t have Docker available you could try an online service such as ElephantSQL.
Let's go!
Setting-up the project
Node basics
To get started, we’re going to create a basic Node.js project. Use the following commands to get started:
mkdir -p typescript-koa && cd typescript-koa
Then we want to create the Node.js project. We can use the shorthand since we’re not going to be creating a live project:
npm init -y
Lastly, we’re going to want to grab our normal Node dependencies:
npm i -D koa koa-{router,bodyparser} http-status-codes typeorm pg reflect-metadata
Now we’re ready to set up TypeScript.
TypeScript setup
We’ve got our base Node dependencies installed, and in theory we could just start now. But we want TypeScript, so let's get that configured. We’ll first need some extra dependencies. These are just going to be development dependencies this time though:
npm i -D typescript ts-node tslint tslint-config-airbnb nodemon
This will give us most of the environment we need to get up and running. Normally, I would advise against using ts-node and advocate Docker instead, but as this is only a brief overview, we’ll run with it for now.
Lastly, we’re going to want to add our type definitions. Previously, we needed to use Typings for this, but now we can install from the @types
organisation. If you want to know more, you should definitely check out the DefinitelyTyped GitHub repository and TypeSearch from Microsoft.
npm i -D @types/{node,koa,koa-router,http-status-codes,koa-bodyparser}
Installing @types/node
will install the type definition for the latest version. If you’re running NodeJS 8 then you’ll need to specify your specific version when installing.
We’ve now got everything we need to get started. The next thing we need is a configuration file to let TypeScript know how to handle our project. Create a file named tsconfig.json
in your repository root and paste the following:
{
"compilerOptions": {
"target": "ES2017",
"module": "commonjs",
"lib": ["es2017"],
"outDir": "dist",
"rootDir": "src",
"noImplicitAny": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
}
}
We won’t go into depth about what each setting does, but if you want to know more you can run node_modules/.bin/tsc --init
. This will create a new config file with all of the available options listed, complete with a comment describing what it does.
Lastly, we’ll set up TSLint. This isn’t a requirement, but it’s a good practice to get into. Create a file named tslint.json
and paste the following:
{
"extends": "tslint-config-airbnb",
"rules": {
"import-name": false
}
}
This includes the TSLint version of the Airbnb rules. The only thing we’re removing is the import name rule as it can be quite restrictive.
We’re almost there..!
Setting-up Nodemon for seamless server restarts
Luckily, as we’re using ts-node
, this isn’t as complicated as it could be as we don’t actually need to compile the TypeScript between each restart.
To restart our server when changes are made, we’re going to watch for changes with Nodemon. Another option would be PM2, which is especially useful when developing in Docker containers, but that’s a larger topic for another time.
My preference is to keep configuration for this in a separate file; to create a nodemon.json
file and add the following:
{
"watch": ["src"],
"exec": "npm run serve",
"ext": "ts"
}
This will watch the src
directory for any changes to .ts
files, and then run npm run start
. This means that we need to set up a start script in our package.json
. Open up your package.json
and add the following scripts:
"scripts": {
"lint": "tslint --project tsconfig.json --format stylish",
"build": "tsc",
"serve": "ts-node src/server.ts",
"start": "nodemon"
}
Here we added a script to lint in case your editor of choice doesn’t do it automatically (personally, I use VSCode with the TSLint extension).
We also added a generic serve command to run the server. This command won’t watch for changed or restart.
Lastly, we made the start script point straight to nodemon
. Nodemon will then in turn run (and re-run) npm serve
whenever anything changes.
As previously mentioned, since we’re using ts-node
we also don’t need to compile at all. That said, we’ve added a build command anyway. This means your project can be run directly from npm
via npm run SCRIPT
(i.e. npm run lint
). We can also skip the run part for the start script, and start our project by running npm start
.
Setting-up PostgreSQL in Docker (optional)
Because we’re going to use PostgreSQL as the database for our API, we’ll need an installation available. For this, we’re going to use Docker. If you’ve got PostgreSQL installed locally, or you have configured an external service, then you can skip this step.
Create a Docker Compose file. Create a file name docker-compose.yml
in your project root, and add the following:
version: '3'
services:
database:
image: postgres:11-alpine
restart: always
expose:
- "5432"
ports:
- "5432:5432"
environment:
POSTGRES_DB: typescript-koa
adminer:
image: adminer:latest
restart: always
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: database
ADMINER_DESIGN: lucas-sandery
This will make PostgreSQL available locally on port 5432, and the Adminer admin GUI available on http://127.0.0.1:8080, once the containers have been started, which we’ll do later.
Now we’re ready to write some code!
Building our API
Getting Koa ready
The first thing we’re going to do it get a basic Koa application running. The app we’re going to create will allow us to store a movie name, release date, and a numeric rating.
Run the following command to create the application file:
mkdir -p src/app && touch src/app/app.ts
This is the file in which we’re going to create our base application. In this file, add the following code:
import * as Koa from 'koa';
import * as HttpStatus from 'http-status-codes';
const app:Koa = new Koa();
// Generic error handling middleware.
app.use(async (ctx: Koa.Context, next: () => Promise<any>) => {
try {
await next();
} catch (error) {
ctx.status = error.statusCode || error.status || HttpStatus.INTERNAL_SERVER_ERROR;
error.status = ctx.status;
ctx.body = { error };
ctx.app.emit('error', error, ctx);
}
});
// Initial route
app.use(async (ctx:Koa.Context) => {
ctx.body = 'Hello world';
});
// Application error logging.
app.on('error', console.error);
export default app;
Koa is entirely middleware-based. The above code is creating an instance of Koa, and adding a small piece of custom middleware to slightly improve our error logging. You’ll probably want something a little more robust for a real application, but this will work nicely for us.
You’ll notice we’re also exporting the app. This serves two purposes:
- It keeps our application modular, and does not tie our app definition to the running of the server.
- It allows us to more easily test the application.
To start our server running, create a file named server.ts
in the src directory: touch src/server.ts
.
In this file, we’re going to import our application and start the server. To do this, we want the following code:
import app from './app/app';
// Process.env will always be comprised of strings, so we typecast the port to a
// number.
const PORT:number = Number(process.env.PORT) || 3000;
app.listen(PORT);
If you now run npm start
, Nodemon should start our server listening on port 3000. If you visit 127.0.0.1:3000
you should be greeted by a nice 'Hello world'.
This is all well and good, but how are we going to create movies with a single endpoint?
Adding our routes
To add our routes, we need to make use of koa-router.
Koa doesn’t ship with a router out-of-the-box, and instead lets you put together your application in a much more modular fashion than other frameworks.
We’re going to create our routes in a separate file for ease of use:
mkdir src/movie && touch src/movie/movie.controller.ts
Open this file, and paste the following code:
import * as Koa from 'koa';
import * as Router from 'koa-router';
const routerOpts: Router.IRouterOptions = {
prefix: '/movies',
};
const router: Router = new Router(routerOpts);
router.get('/', async (ctx:Koa.Context) => {
ctx.body = 'GET ALL';
});
router.get('/:movie_id', async (ctx:Koa.Context) => {
ctx.body = 'GET SINGLE';
});
router.post('/', async (ctx:Koa.Context) => {
ctx.body = 'POST';
});
router.delete('/:movie_id', async (ctx:Koa.Context) => {
ctx.body = 'DELETE';
});
router.patch('/:movie_id', async (ctx:Koa.Context) => {
ctx.body = 'PATCH';
});
export default router;
This defines our routes. You’ll notice that we’ve set a prefix of /movies
. This is so that when we mount our routes into the application we don’t have to add any configuration at all at the application level. If we want to split this functionality out into its own package, or move it around, we can just move it.
If you’ve used Express, you’ll probably be able to make sense of what’s going on. There are methods on the router object that represent the HTTP verbs that will be used for our API and then a callback. What might be new is that the callbacks are async functions.
By leveraging async functions, Koa allows you to ditch callbacks and greatly increase error-handling.
Next, we need to make this controller available to the application. At the top of app.js
, import the movie controller:
import movieController from '../movie/movie.controller';
And then remove our default 'Hello world' endpoint, and replace it with the following:
// Route middleware.
app.use(movieController.routes());
app.use(movieController.allowedMethods());
The .routes()
part adds the route middleware to the application, and the .allowedMethods()
function will add another piece of middleware that will ensure correct responses are given for disallowed or non-implemented methods.
If you now make any request using any of the above HTTP verbs to our API, you should get a response that mentions the type of request you made.
Great stuff! Now we need to add our database backend.
Implementing our persistence layer
Adding the database connection
This section requires a PostgreSQL database. If you’ve set up the dockerfile above then now is a good time to run docker-compose
up in a new terminal window. If you’ve created your database somewhere else, make sure you have the credentials to hand.
The first thing we need to do is establish our database connection. Run the following to create the file in which we’ll store our database credentials:
mkdir src/database && touch src/database/database.connection.ts
Open this file and paste the following:
import 'reflect-metadata';
import { createConnection, Connection, ConnectionOptions } from 'typeorm';
import { join } from 'path';
const parentDir = join(__dirname, '..');
const connectionOpts: ConnectionOptions = {
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT) || 5432,
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'typescript-koa',
entities: [
`${parentDir}/**/*.entity.ts`,
],
synchronize: true,
};
const connection:Promise<Connection> = createConnection(connectionOpts);
export default connection;
This file creates our connection using TypeORM. You can find documentation for all of the configuration options here.
We’re also exporting this so we can pull it into our bootstrapping server.ts
file. If you’re running your own PostgreSQL instance then you can change the default connection details above.
The way we have it set up allows for environment variables to be the default source of truth, and we’ve got non-sensitive local credentials as a backup.
As a side note, if you’re running your application directly via NodeJS, and transpiling your .ts
files to JavaScript, you’ll need to add ${parentDir}/**/*.entity.js
to your entities
list.
Next, revisit server.ts
and change the content to the following:
import app from './app/app';
import databaseConnection from './database/database.connection';
const PORT:number = Number(process.env.PORT) || 3000;
databaseConnection
.then(() => app.listen(PORT))
.catch(console.error);
The connection we creates returns a promise. Since our application is dependant on a database, we can safely start the server in the success callback for the database connection.
Defining the data model
TypeORM refers to its data models as entities. An entity is a class that is wrapped with TypeScript decorators to add underlying functionality. It’s very similar to how Doctrine works in PHP. We only need one entity for our demo, so we’re going to create an entity class next:
touch src/movie/movie.entity.ts
In this file, paste the following code:
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
@Entity()
export default class Movie {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
name: string;
@Column({ type: 'int', nullable: true, width: 4 })
releaseYear: number;
@Column({ type: 'int', nullable: true })
rating: number;
}
Believe it or not, this is the only data we need to be able to gain access to our entire persistence layer. Checkout the official documentation to learn more about all the different options for creating entities.
Once this is all saved and the server is restarted you should notice that we now have a movie table in our database! That means it’s time to update our routes.
Updating routes with persistence
The last thing we’ll need to do it update our routes with the functionality required to persist, edit, and delete our movie entities.
Visit our original app.ts
file and, at the top, paste the following to import the body parser:
import * as bodyParser from 'koa-bodyparser';
This is required by Koa to be able to read the request body. Just above the route use
calls, we’ll also want to add the following:
// Middleware
app.use(bodyParser());
Open movie.controller.ts
again and replace everything with the following code (it’a a rather large snippet, so comments have been added throughout to help illustrate what’s going on):
import * as Koa from 'koa';
import * as Router from 'koa-router';
import { getRepository, Repository } from 'typeorm';
import movieEntity from './movie.entity';
import * as HttpStatus from 'http-status-codes';
const routerOpts: Router.IRouterOptions = {
prefix: '/movies',
};
const router: Router = new Router(routerOpts);
router.get('/', async (ctx:Koa.Context) => {
// Get the movie repository from TypeORM.
const movieRepo:Repository<movieEntity> = getRepository(movieEntity);
// Find the requested movie.
const movies = await movieRepo.find();
// Respond with our movie data.
ctx.body = {
data: { movies },
};
});
router.get('/:movie_id', async (ctx:Koa.Context) => {
// Get the movie repository from TypeORM.
const movieRepo:Repository<movieEntity> = getRepository(movieEntity);
// Find the requested movie.
const movie = await movieRepo.findOne(ctx.params.movie_id);
// If the movie doesn't exist, then throw a 404.
// This will be handled upstream by our custom error middleware.
if (!movie) {
ctx.throw(HttpStatus.NOT_FOUND);
}
// Respond with our movie data.
ctx.body = {
data: { movie },
};
});
router.post('/', async (ctx:Koa.Context) => {
// Get the movie repository from TypeORM.
const movieRepo:Repository<movieEntity> = getRepository(movieEntity);
// Create our new movie.
const movie: movieEntity = movieRepo.create(ctx.request.body);
// Persist it to the database.
await movieRepo.save(movie);
// Set the status to 201.
// Respond with our movie data.ctx.status = HttpStatus.CREATED;
ctx.body = {
data: { movie },
};
});
router.delete('/:movie_id', async (ctx:Koa.Context) => {
// Get the movie repository from TypeORM.
const movieRepo:Repository<movieEntity> = getRepository(movieEntity);
// Find the requested movie.
const movie = await movieRepo.findOne(ctx.params.movie_id);
// If the movie doesn't exist, then throw a 404.
// This will be handled upstream by our custom error middleware.
if (!movie) {
ctx.throw(HttpStatus.NOT_FOUND);
}
// Delete our movie.
await movieRepo.delete(movie);
// Respond with no data, but make sure we have a 204 response code.
ctx.status = HttpStatus.NO_CONTENT;
});
router.patch('/:movie_id', async (ctx:Koa.Context) => {
// Get the movie repository from TypeORM.
const movieRepo:Repository<movieEntity> = getRepository(movieEntity);
// Find the requested movie.
const movie:movieEntity = await movieRepo.findOne(ctx.params.movie_id);
// If the movie doesn't exist, then throw a 404.
// This will be handled upstream by our custom error middleware.
if (!movie) {
ctx.throw(HttpStatus.NOT_FOUND);
}
// Merge the existing movie with the new data.
// This allows for really simple partial (PATCH).
const updatedMovie = await movieRepo.merge(movie, ctx.request.body);
// Save the new data.
movieRepo.save(updatedMovie);
// Respond with our movie data.// Response with the updated content.
ctx.body = {
data: { movie: updatedMovie },
};
});
export default router;
The above enables GET
, POST
, PATCH
, and DELETE
endpoints to interact with our movie
entity.
That should now be everything we need!
You can test our new API by making a POST
request.
Using Insomnia or Postman (or similar), build a request with the following data and send it to http://127.0.0.1:3000/movies
:
{
"name": "Main in Manhattan",
"releaseYear": 2002,
"rating": 10
}
You should get the movie instance returned. Make a note of the UUID
, and we can use that to build a GET
request to http://127.0.0.1:3000/movies/{UUID}
.
Summary
TypeScript can greatly increase productivity with medium to large applications. From the perspective of my personal experience, it’s reduced the amount of tests I’ve had to write by around a third, as well as catching type errors – literally as I type, rather than whilst testing endpoints.
I’ve also only recently tried TypeORM, moving over from Mongoose. Whilst Mongoose was enjoyable and relatively simply to use there was a lot of boilerplate required to get up and running with TypeScript (and in general).
TypeORM is a breath of fresh air, and it's unbelievably quick to use. Hopefully this tutorial has helped to demonstrate this.
You can find the code for the above article here.