Written by Rose Chege✏️
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.
> 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:
- Getting tasks, or GET
http://localhost:3000/api/tasks
- Creating task, or POST
http://localhost:3000/api/tasks
- Updating tasks, or PUT
http://localhost:3000/api/tasks)
- Deleting tasks, or DELETE
http://localhost:3000/api/tasks
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.
Top comments (0)