Rose Chege Rose is a lover of technology and an upright individual who is not afraid to get out of her comfort zone and try out new programming paradigms.

How to build a REST API with TypeScript using only native modules

9 min read 2764

TypeScript is a typed superset of JavaScript that compiles to plain JavaScript during runtime. The dynamic nature of JavaScript does not allow you to catch any unexpected results unless the program has been executed, so the TypeScript type system will enable you to catch such errors at compile time instead of runtime. This means that any valid JavaScript code is also equivalent and valid TypeScript code.

TypeScript is both a language and a set of tools. As a language, it comprises syntax, keywords, and type annotations. Its toolset provides type information that the IDE can use to supply autocompletion, type hinting, refactoring options, static typing, and other features.

The TypeScript and JavaScript ecosystem offers diverse tools and libraries that help you build your application faster. For example, you will probably use the Express library to set up your API when creating a server. Express provides you with functions to set up and manage your HTTP server at scale.

However, these libraries are developed using vanilla JavaScript. Behind the scenes, your Express server runs on raw TypeScript or JavaScript. This means the functions and methods that Express provides abstract the low-level logic of the vanilla base language. Your server does not directly interact with the base logic that the TypeScript or JavaScript provides; instead, Express accesses the vanilla code and transforms it to scalable code that lets you reduce your codebase.

Using these libraries speeds up your development time while reducing redundant code; the advantage they provide is undisputed. However, you might want to leverage the “bare bones” of TypeScript and run your apps using the vanilla code. This will help you execute the purest server without using libraries such as Express.

This guide will demonstrate how to use TypeScript to create a REST API using only the native modules. The project aims to help you learn how to make an HTTP server in Node.js without using additional libraries.

How to set up TypeScript with Node.js

This tutorial will use Node to run TypeScript. Node is a JavaScript runtime designed to create scalable asynchronous event-driven applications.

Go ahead and install Node runtime on your computer. Then, create a project folder and initialize your project using npm init -y.

Let’s configure Node to run TypeScript code. You need TypeScript dependencies available for Node. To do so, install the TypeScript Node package using the following command:

npm install -D typescript

You can now utilize your TypeScript project using tsc --init. This will create a tsconfig.json file with the default TypeScript compile options.

We made a custom demo for .
No really. Click here to check it out.

tsc --init may require you to install TypeScript globally on your computer using the command npm install -g typescript.

While running the TypeScript code, you need to execute the above dependencies using a Node command. To do this, use a TypeScript execution engine and REPL library called ts-node. Ts-node allows you to run a one-time command that points to .ts files, compile and run them on the browser.

Go ahead and install ts-node like so:

npm install -D ts-node 

Then, edit your package.json script tags:

"scripts": {
   "start": "ts-node ./src/index.ts"
},

This means ts-node will point the /src/index.ts file as the main file and execute the .ts code and modules that index.ts points to.

Finally, add a @types/node library to your project. Node runs on JavaScript, and this project uses TypeScript. Thus, you need to add type definitions for Node to run TypeScript.

@types/node contains built-in TypeScript definitions. To install it, run:

npm install @types/node

How to create a simple TypeScript HTTP server

The TypeScript Node setup is ready to run your code. Let’s see how to create a basic HTTP server that runs using the HTTP native module.

First, create an src folder and add an index.ts file. Then, set up a basic TypeScript HTTP server using Node with the following steps.

To begin, import the HTTP native module:

import HTTP from "HTTP";

To create an HTTP server and client, you need to use the HTTP command from "http". This will allow you to have the necessary functions to create a server.

Next, create a local server from which to receive data:

const myServer = http.createServer((req, res) => {
   res.write('Hello World!')
   res.end()
});

Set up a server instance using the createServer() function. This function allows you to issue HTTP requests and responses. The res.write code allows you to specify the incoming message that the server should execute. res.end() ends the set incoming requests even when no data is written to the body of the request or sent by the HTTP response.

Then, start the server and listen for connections:

myServer.listen(3000, () => {
   console.log('Server is running on port 3000. Go to http://localhost:3000/')
});

myServer.close()

listen() will create a localhost TCP server listening on port 3000. In this case, 3000 must be the unused port that the server will be immediately get assigned to once it starts listening for connections. The listen() method is asynchronous, and manages how the server accepts new connections while exiting the current ones. When all connections have ended, the server is asynchronously closed. If an error occurs, the server will be called with an Error and closed immediately.

Once you run your app using npm start, the server will start, and you can access it on http://localhost:3000/. The Hello World! message specified in the response body will be served on your browser. This basic HTTP API is very low-level and runs on the most basic of TypeScript.

How to create a CRUD TypeScript REST API

The above example only used the HTTP module to set a basic server. Let’s dive in and build a REST API that uses the CRUD (create, read, update, and delete) methods.

To set up this API, start by storing our sample to-do list in a JSON file. Then, create a store.json file inside the src folder and add the following list of tasks:

[
   {
     "id": 1,
     "title": "Learn React",
     "completed": false
   },
   {
     "id": 2,
     "title": "Learn Redux",
     "completed": false
   },
   {
     "id": 3,
     "title": "Learn React Router",
     "completed": false
   },
   {
     "id": 4,
     "title": "Cooking Lunch",
     "completed": true
   }
]

The to-do list API will refer to this data to perform server-based methods like GET, POST, PUT, and DELETE.

Setting up the task structure

When using TypeScript, you can use classes, inheritance, interfaces, and other object-oriented forms. JavaScript uses classes, but these classes are templates for JavaScript objects.

JavaScript has no interfaces, because they are only available in TypeScript. Interfaces help you define types that keep you within the margins of your code. This ensures that parameters and variable structures are strongly typed.

Interfaces basically mirror the structure of an object that can be passed to classes as a parameter or implemented by a class. They define the structure and specify the types only once. Thus, you can reuse them anywhere in your code without having to duplicate the same types every time.

You can use interfaces to mirror the structure of the task data. This will specify the structure of the object you want your API to interact with. In this case, when you call the API, you get the task information with the same structure that mirrors the task data.

Let’s use interfaces to define what properties the to-do list API should have. Create a new file inside the src directory and call it ITask.ts.

Inside this file, define the task structure as such:

// Task structure
interface Task {
   id: number;
   title: string;
   completed: boolean;
}

export { Task }

This will create a model that defines the domain data. Ensure you export it so that other project modules can access its properties.

Adding the API controllers

A controller is used to handle the HTTP requests that send back an HTTP response. The API controller function handles these requests for an endpoint. A controller regulates the structure defined in ITask.ts and the data that an API endpoint returns to the user. In this case, each controller will handle the logic handling each CRUD operation.

Go ahead and create a controller.ts file inside the src directory. Then, add the following imports and create each CRUD controller like so:

// access the data store (store.json)
import fs from "fs";
import path from "path";

// handle requests and reponses
import { ServerResponse, IncomingMessage } from "http";

// access the task structure
import { Task } from "./ITask";

Fetching tasks

Create a function getTasks(). This function fetches all the tasks listed in the store.json file:

const getTasks = (req: IncomingMessage, res: ServerResponse) => {
   return fs.readFile(
     path.join(__dirname, "store.json"),
     "utf8",
     (err, data) => {
       // Read the store.json file
       // Check out any errors
       if (err) {
         // error, send an error message
         res.writeHead(500, { "Content-Type": "application/json" });
         res.end(
           JSON.stringify({
             success: false,
             error: err,
           })
         );
       } else {
         // no error, send the data
         res.writeHead(200, { "Content-Type": "application/json" });
         res.end(
           JSON.stringify({
             success: true,
             message: JSON.parse(data),
           })
         );
       }
     }
   );
};

The user sends a request using an endpoint that points to the getTasks() function. This controller will receive that request and what that request wants to do. Then, the ITask interface will set the data and give the response. The controller getTasks() will get this response and pass its data to the executed endpoint. In this case, the controller will read the data stored in the store.json file and return the to-do list.

Adding a new a task

To begin, create a function called addTask(). This addTask() controller will handle the logic of adding a new task, like so:

const addTask = (req: IncomingMessage, res: ServerResponse) => {
   // Read the data from the request
   let data = "";

   req.on("data", (chunk) => {
     data += chunk.toString();
   });

   // When the request is done
   req.on("end", () => {
     let task = JSON.parse(data);

     // Read the store.json file
     fs.readFile(path.join(__dirname, "store.json"), "utf8", (err, data) => {
       // Check out any errors
       if (err) {
         // error, send an error message
         res.writeHead(500, { "Content-Type": "application/json" });
         res.end(
           JSON.stringify({
             success: false,
             error: err,
           })
         );
       } else {
         // no error, get the current tasks
         let tasks: [Task] = JSON.parse(data);
         // get the id of the latest task
         let latest_id = tasks.reduce(
           (max = 0, task: Task) => (task.id > max ? task.id : max),
           0
         );
         // increment the id by 1
         task.id = latest_id + 1;
         // add the new task to the tasks array
         tasks.push(task);
         // write the new tasks array to the store.json file
         fs.writeFile(
           path.join(__dirname, "store.json"),
           JSON.stringify(tasks),
           (err) => {
             // Check out any errors
             if (err) {
               // error, send an error message
               res.writeHead(500, { "Content-Type": "application/json" });
               res.end(
                 JSON.stringify({
                   success: false,
                   error: err,
                 })
               );
             } else {
               // no error, send the data
               res.writeHead(200, { "Content-Type": "application/json" });
               res.end(
                 JSON.stringify({
                   success: true,
                   message: task,
                 })
               );
             }
           }
         );
       }
     });
   });
};

In the code above, a user sends a request using an endpoint that points to the AddTasks() function. This controller will first read the data from the request, which is adding a new task. Then, it reads the store.json file and prepares it to receive new data entries. The ITask interface will set the properties needed to create a new task and give the response to AddTasks().

If the sent request matches the structure set by ITask, AddTasks() will accept its message and write the new task details to the store.json file.

Updating a task

You might want to update the values of an existing task. This will require you to send a request to inform the save that you want to update some values.

To do so, create an updateTask() function like so:

const updateTask = (req: IncomingMessage, res: ServerResponse) => {
   // Read the data from the request
   let data = "";
   req.on("data", (chunk) => {
     data += chunk.toString();
   });
   // When the request is done
   req.on("end", () => {
     // Parse the data
     let task: Task = JSON.parse(data);
     // Read the store.json file
     fs.readFile(path.join(__dirname, "store.json"), "utf8", (err, data) => {
       // Check out any errors
       if (err) {
         // error, send an error message
         res.writeHead(500, { "Content-Type": "application/json" });
         res.end(
           JSON.stringify({
             success: false,
             error: err,
           })
         );
       } else {
         // no error, get the current tasks
         let tasks: [Task] = JSON.parse(data);
         // find the task with the id
         let index = tasks.findIndex((t) => t.id == task.id);
         // replace the task with the new one
         tasks[index] = task;
         // write the new tasks array to the store.json file
         fs.writeFile(
           path.join(__dirname, "store.json"),
           JSON.stringify(tasks),
           (err) => {
             // Check out any errors
             if (err) {
               // error, send an error message
               res.writeHead(500, { "Content-Type": "application/json" });
               res.end(
                 JSON.stringify({
                   success: false,
                   error: err,
                 })
               );
             } else {
               // no error, send the data
               res.writeHead(200, { "Content-Type": "application/json" });
               res.end(
                 JSON.stringify({
                   success: true,
                   message: task,
                 })
               );
             }
           }
         );
       }
     });
   });
};

This will check the data sent against the existing data stored in the store.json file. In this case, the server will check if the ID value matches any existing tasks. ITask will check if the update values match the set structure, and return a response to updateTask(). If so, the value will be updated and a response sent to the requesting endpoint.

Deleting a task

Likewise, you can delete a task from the storage. Here is the code to help you send a request that executes a delete request:

const deleteTask = (req: IncomingMessage, res: ServerResponse) => {
   // Read the data from the request
   let data = "";
   req.on("data", (chunk) => {
     data += chunk.toString();
   });
   // When the request is done
   req.on("end", () => {
     // Parse the data
     let task: Task = JSON.parse(data);
     // Read the store.json file
     fs.readFile(path.join(__dirname, "store.json"), "utf8", (err, data) => {
       // Check out any errors
       if (err) {
         // error, send an error message
         res.writeHead(500, { "Content-Type": "application/json" });
         res.end(
           JSON.stringify({
             success: false,
             error: err,
           })
         );
       } else {
         // no error, get the current tasks
         let tasks: [Task] = JSON.parse(data);
         // find the task with the id
         let index = tasks.findIndex((t) => t.id == task.id);
         // remove the task
         tasks.splice(index, 1);
         // write the new tasks array to the store.json file
         fs.writeFile(
           path.join(__dirname, "store.json"),
           JSON.stringify(tasks),
           (err) => {
             // Check out any errors
             if (err) {
               // error, send an error message
               res.writeHead(500, { "Content-Type": "application/json" });
               res.end(
                 JSON.stringify({
                   success: false,
                   error: err,
                 })
               );
             } else {
               // no error, send the data
               res.writeHead(200, { "Content-Type": "application/json" });
               res.end(
                 JSON.stringify({
                   success: true,
                   message: task,
                 })
               );
             }
           }
         );
       }
     });
   });
};

Finally, export these controllers so that other application modules can access them:

export { getTasks, addTask, updateTask, deleteTask };

Setting the server and the task routes

Once your controllers are set, you need to create and expose various endpoints for creating, reading, updating, and deleting tasks. Endpoints are URLs that execute the requesting data.

This endpoint will be used in combination with an HTTP method to perform a specific action on the data. These HTTP methods include GET, POST, PUT, and DELETE. Each HTTP method will be mapped to a single controller that matches its defined routing.

Navigate to your ./src/index.ts file and set your method endpoints as such:

import HTTP from "HTTP";

// import controller
import { getTasks, addTask, updateTask, deleteTask } from "./controller";

// create the http server
const server = http.createServer((req, res) => {
   // get tasks
   if (req.method == "GET" && req.url == "/api/tasks") {
     return getTasks(req, res);
   }

   // Creating a task
   if (req.method == "POST" && req.url == "/api/tasks") {
     return addTask(req, res);
   }

   // Updating a task
   if (req.method == "PUT" && req.url == "/api/tasks") {
     return updateTask(req, res);
   }

   // Deleting a task
   if (req.method == "DELETE" && req.url == "/api/tasks") {
     return deleteTask(req, res);
   }
});

// set up the server port and listen for connections
server.listen(3000, () => {
   console.log("Server is running on port 3000");
});

This defines four endpoints:

This will also expose this endpoint on the local host. The server will be mapped to port 3000.

Once it is up and running, it will listen for connections based on the execute routes.
The to-do list API is ready, and you can run it using npm start and start testing different endpoints.

Conclusion

Running your application with vanilla code gives you an idea of the code running the base of your apps. Vanilla TypeScript will help you create packages that other developers can use to speed up their development workflow.

The biggest drawback of any vanilla code is that you will be required to write many lines of code to execute an average task. The same task can still run using the packages that allow you to write a few lines of code. This means when running vanilla code, you will have to manage most operations within your application.

Writing a lot of TypeScript? Watch the recording of our recent TypeScript meetup to learn about writing more readable code.

TypeScript brings type safety to JavaScript. There can be a tension between type safety and readable code. Watch the recording for a deep dive on some new features of TypeScript 4.4.

Rose Chege Rose is a lover of technology and an upright individual who is not afraid to get out of her comfort zone and try out new programming paradigms.

Leave a Reply