How to create a REST API with TypeScript and Mongoose

How to create a REST API with TypeScript and Mongoose

In this tutorial I would like to share my journey on how I set up my first TypeScript REST API and what I actually learned during the process.

First things first, what’s TypeScript?

TypeScript is a superset of JavaScript (meaning you can use everything that you use in JavaScript, and build further features on it). Pretty much JS on steroids. TypeScript is compiled to plain JS. It is excellent for larger applications because it allows you to write clean, maintainable, reusable code.

What are the main features of TypeScript?

1. Static type checking

You can create variables and define what you want to store inside that variable. JS itself doesn’t give you this option, we can store anything in “let, const” or “var”. With this feature we can avoid bugs that can be caused by this and make our code more straightforward and easier to descriptive. Because TS builds on JS, we have the option NOT to use this feature, so it’s really up to you and how you decide.

2. Object Oriented Programming without prototypes

We can use the ES6 way of creating new classes and objects without the need to use prototypes which could be a burden. It also allows you to create private, public, protected classes.

3. Full ES6 support, including imports/exports

Because our code will be compiled before running, we might as well use syntax that is not supported by Node itself at this point, so why not take this opportunity?

There are many more benefits of TypeScript, but it’s time to build our first app!

Let’s get started by installing TypeScript and set up all the packages that we will use. I will use Concurrently, an npm package that allows you to run multiple npm scripts at the same time.

npm i -g typescript concurrently

Inside our project folder, initialize our TS app and our package.json the following way:

npm init
npm i @types/express express @types/mongoose mongoose body-parser

Inside our package.json file we will see the installed packages, it’s time to set up a few scripts that will save us from typing all the commands over and over again. Just paste this snippet:

"scripts": {
  "watch-ts": "tsc -w",
  "watch-node": "nodemon dist/app.js",
  "watch": "concurrently \"npm run watch-ts\" \"npm run watch-  node\""
}

TypeScript needs a config file that it will use as a manual when it comes to compile our code. It will check our settings that we defined and compile the way we specified. To create this file, run the following command in the terminal and it will serve you a file called tsconfig.json.

I am not getting into it too much for now, I modified the file as little as I could to compile the way I want, and here is my tsconfig.json file, feel free to copy paste it:

{
  "compilerOptions": {
    /* Basic Options */
    "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
    "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
    // "lib": [],                             /* Specify library files to be included in the compilation. */
    // "allowJs": true,                       /* Allow javascript files to be compiled. */
    // "checkJs": true,                       /* Report errors in .js files. */
    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
    "sourceMap": true /* Generates corresponding '.map' file. */,
    // "outFile": "./",                       /* Concatenate and emit output to single file. */
    "outDir": "./dist" /* Redirect output structure to the directory. */,
    // "rootDir": "" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
    // "composite": true /* Enable project compilation */,
    // "incremental": true,                   /* Enable incremental compilation */
    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
    // "removeComments": true,                /* Do not emit comments to output. */
    // "noEmit": true,                        /* Do not emit outputs. */
    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    "strict": true /* Enable all strict type-checking options. */,
    // "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
    // "strictNullChecks": true,              /* Enable strict null checks. */
    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
    // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */

    /* Additional Checks */
    // "noUnusedLocals": true,                /* Report errors on unused locals. */
    // "noUnusedParameters": true,            /* Report errors on unused parameters. */
    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */

    /* Module Resolution Options */
    "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
    // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
    // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
    // "typeRoots": [],                       /* List of folders to include type definitions from. */
    // "types": [],                           /* Type declaration files to be included in compilation. */
    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */

    /* Source Map Options */
    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
  }
}

Now that our setup is done, let’s get started with a simple Express server.

Create a folder called “src”, this is where we will store all of our TypeScript files that will be compiled later. Inside here create a file called App.ts. The content of this will be almost identical, except the specific types imported for Express. Something like this:

app.ts
import express, { Application, Request, Response, NextFunction } from "express";
import bodyParser from "body-parser"

const app: Application = express();
const port: number = 5000 || process.env.PORT;

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


app.get("/", (req: Request, res: Response, next: NextFunction) => {
  res.send("Hello World");
});

app.listen(port, () => {
  console.log(`Server running on ${port}`);
});

If we run “npm run watch”, it will compile our code and spin up our server, so if we navigate to “http://localhost:5000” in our browser, we will see the very original message, “Hello World”.

Time to get started with the Mongoose schema that will define how our entries in the database will look like. First, let’s create a new folder inside our “src” folder, called “models”. We will store all of our models here, but for this tutorial I stick to something simple. We will store books in the database. So inside our newly created “models” folder, create a file called “book.ts”. To create a schema, paste this code inside the file:

book.ts
import mongoose, { Schema, Document } from "mongoose";

export interface BookInterface extends Document {
  title: string;
  author: string;
}

const BookSchema: Schema = new Schema({
  title: { type: String, required: true },
  author: { type: String, required: true }
});

const Book = mongoose.model<BookInterface>("Book", BookSchema);
export default Book;

Here you can see as we import the modules that we use to create the schema. After I created an interface, this feature is one of my favourites when it comes to TypeScript. It allows you to reuse this code, so you don’t have to always type in key-value pairs, just call the interface. After this we create the schema and define the attributes our entries will have in the database, in this case it will be “title” and “author”. In the end we just create the schema and export it.

Now it’s time to connect to our database. First let’s create a file inside our “src” folder called “connect.ts”. Inside this file we will have the basic boilerplate code that you use with Mongoose to connect. Something like:

connect.ts
import mongoose from "mongoose";

export default (db: string) => {
  const connect = () => {
    mongoose
      .connect(db, { useNewUrlParser: true })
      .then(() => {
        return console.log(`Successfully connected to ${db}`);
      })
      .catch(error => {
        console.log("Error connecting to database: ", error);
        return process.exit(1);
      });
  };
  connect();

  mongoose.connection.on("disconnected", connect);
};

We can use this code in the “app.ts” file, I just like to keep things separated. Now let’s update our “app.ts” file to use this file and connect to our database. Our updated file will look like this:

app.ts
import express, { Application, Request, Response, NextFunction } from "express";
import bodyParser from "body-parser";

import connect from "./connect";


const app: Application = express();
const port: number = 5000 || process.env.PORT;
const db: string = "mongodb://<username>:<password>@mongo.mlab.com:<port>/<database_name>"

connect(db);

app.listen(port, () => {
  console.log(`Server running on ${port}`);
});

Now I will create a folder called “controllers” where we will store all our controllers. Inside a file called “book_controller.ts” I wrote the following code:

book_controller.ts
import { Request, Response } from "express";
import Book from "../models/book";

export const allBooks = (req: Request, res: Response) => {
  const books = Book.find((err: any, books: any) => {
    if (err) {
      res.send(err);
    } else {
      res.send(books);
    }
  });
};

export const showBook = (req: Request, res: Response) => {
  const book = Book.findById(req.params.id, (err: any, book: any) => {
    if (err) {
      res.send(err);
    } else {
      res.send(book);
    }
  });
};

export const addBook = (req: Request, res: Response) => {
  const book = new Book(req.body);
  book.save((err: any) => {
    if (err) {
      res.send(err);
    } else {
      res.send(book);
    }
  });
};

export const updateBook = (req: Request, res: Response) => {
  let book = Book.findByIdAndUpdate(
    req.params.id,
    req.body,
    (err: any, book: any) => {
      if (err) {
        res.send(err);
      } else {
        res.send(book);
      }
    }
  );
};

export const deleteBook = (req: Request, res: Response) => {
  const book = Book.deleteOne({ _id: req.params.id }, (err: any) => {
    if (err) {
      res.send(err);
    } else {
      res.send("Book deleted from database");
    }
  });
};

This will handle the basic requests and reach into my database and retrieve/modify the data that we want. If you are not familiar with these methods, feel free to check out the Mongoose documentation which gives you a thorough understanding how it works.

Now that our database connection is set up and we are connected to it, our controller is ready, all there is left to define the routes that will trigger the corresponding controller method. To do this, I imported the newly created controller file into “app.ts” and created the 5 basic routes and attached the controller methods to it. In the end our “app.ts” file will look like this:

app.ts
import express, { Application } from "express";
import bodyParser from "body-parser";

import connect from "./connect";
import { db } from "./config/config";
import * as BookController from "./controllers/book_controller";

const app: Application = express();
const port: number = 5000 || process.env.PORT;

connect(db);

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

app.get("/books", BookController.allBooks);

app.get("/books/:id", BookController.showBook);

app.post("/books", BookController.addBook);

app.patch("/books/:id", BookController.updateBook);

app.delete("/books/:id", BookController.deleteBook);

app.listen(port, () => {
  console.log(`Server running on ${port}`);
});

Spin up your server and create your first entry in the database by sending a POST request to “http://localhost:5000/books” and the data you want to used and that’s it! Play around, experiment, extend this app and have fun!

You can find the Github repository with the completed code here.