Command Line Interface Challenge
In this chapter, we will begin our development journey by implementing a simple command line interface (CLI) application. We will implement a TODO list application that can perform basic operations in this chapter and followings. We will improve our TODO list application step by step. You will see bad examples and good examples in each chapter. The main purpose of of this part of the book is to show how to improve your coding skills by using different techniques and technologies.
Requirements
We want to implement a simple TODO list application that works on the command line. Before starting to implement the application, we need to define the requirements of the application. Lets define our requirements in two different categories: functional and non-functional requirements.
A functional requirement describes what a software system should do. It specifies the behavior of the system. In our case, the functional requirements of the TODO list application might be like this:
- As a user, I should be able to list all of the todos that is created before.
- As a user, I should be able to add a new todo to the list.
- The todo should have a unique identifier.
- Title of the todo must be a string and it is required.
- Maximum length of the title is 255 characters.
- Minimum length of the title is 2 character.
- Description of the todo must be a string and it is optional.
- Maximum length of the description is 4096 characters.
- The todo should have a status, it is either done or undone.
- As a user, I should be able to mark a todo as done.
- As a user, I should be able to mark a todo as undone.
- As a user, I should be able to delete a todo from the list.
A non-functional requirement describes how a software system should do. It specifies more technical aspects of the system. In our case, the non-functional requirements of the TODO list application might be like this:
- The application should be implemented in TypeScript.
- The application should use asynchronous operations instead of synchronous (blocking) operations.
- As a user, I should be able to add a todo by giving this command:
tdr add "A sample task" "A sample task description"
. - As a user, I should be able to list all of the todos by giving this command:
tdr list
. - As a user, I should be able to mark a todo as done by giving this command:
tdr done id-of-todo
. - As a user, I should be able to mark a todo as undone by giving this command:
tdr undone id-of-todo
. - As a user, I should be able to delete a todo by giving this command:
tdr delete id-of-todo
. - The application should store all of the todos in a JSON file.
We are using JSON file to store the todos. This is not the best way to store the todos but we will use it for simplicity. In the following chapters, we will improve the storage mechanism of the todos. Here is a sample JSON file that stores a single todo:
[
{
"id": 1,
"title": "A sample task description",
"description": "This is a sample task description",
"done": false
}
]
Also, as a non-functional requirement we will use asynchronous file operations in this chapter1. We will use the fs
module of the Node.js to read and write the JSON file. The fs
module provides basic file operations in two different
ways: synchronous and asynchronous. For instance, fs.readFileSync
function is used to read a file synchronously and
fs.readFile
function is used to read a file asynchronously. When you use fs.readFileSync
function, the execution of
the program is blocked until the file is read. When you use fs.readFile
function, the execution of the program is not
blocked and the file is read in the background. Another variant of the fs
module is the fs.promises
module. The
fs.promises
module provides the same functions as the fs
module but they return promises instead of using callbacks.
We will use the fs.promises
module in this chapter.
Project Scaffolding
Each software project has a structure and each of them starts with a scaffolding. Scaffolding is a process of creating the initial structure of the project. I will not use any project scaffolding tools in this book and also I do not recommend you to use them. Because, they will hide the underlying concepts and technologies from you. Instead of that, I will create the initial structure of the project by hand and I will explain each step in detail. So, you can understand the underlying concepts and technologies in detail.
# Create a new directory for our project
mkdir tdr
# Change the directory to the project directory
cd tdr
# Initialize the git repository
git init
# Initialize the .gitignore file
curl https://www.toptal.com/developers/gitignore/api/linux,macos,windows,node > .gitignore
# Initialize the package.json file
npm init -y
# Create the src directory
mkdir src
touch src/index.ts
# Create a directory for Github Actions
mkdir .github
# Create a directory for VSCode settings
mkdir .vscode
touch .vscode/settings.json
# Initialize the code formatter
npm i -D prettier
touch .prettierrc
# Install the typescript and tsx (Typescript execute)
npm i -D typescript tsx @types/node
# Initialize the typescript configuration
npx tsc --init
# Create a README.md file
touch README.md
As you can see, the script above starts by creating a new directory named tdr
and then it changes the current directory
to the tdr
directory 2. After that, it initializes a git repository in the created directory3.
After initializing the git repository, we are adding a .gitignore
file to the project. Here, instead of creating
a .gitignore
file by hand, we are using a gitignore generator tool which is served by Toptal. As a reminder,
.gitignore
file is used to specify which files and directories should be ignored by git. These file name patterns are
generally does not change too much. For this reason, we can use a predefined .gitignore
file for our project. For this
reason, we are sending an HTTP request to the Toptal gitignore generator tool and we are saving the response to the
.gitignore
file. Please not that, these files start with a dot (.
) character which means that they are hidden files.
After that, we are creating a package.json
file by using the npm init -y
command. This command creates a
package.json
file with default values. We will update this file later. Simply package.json
file is used to store
metadata of the project such as project name, version, description, dependencies, and scripts.
After creating the package.json
file, we are creating a src
directory and an index.ts
file in it. We will use the
src
directory to store all of our source files and the index.ts
file will be the entry point of our application.4
After these operations, we are creating two empty directories named .vscode
and .github
. We will use the .vscode
directory to store the Visual Studio Code related configurations and the .github
directory to store the GitHub related
configurations. Lets add some configurations to the .vscode
directory in order to format our code automatically when
we save the file. We can add the following lines to the .vscode/settings.json
file:
{
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
These settings will be pushed to the GitHub repository and they will be used by the other developers who will clone the repository. This feature is very useful because it ensures that all of the developers use the same settings and the same tools. This way, we can create a consistent codebase.
A tool that we will use so much in this book is prettier
. Prettier is a code formatter tool that can format your code
automatically. In order to use the prettier
tool, we need to install it first. We are installing it as a development
dependency by giving -D
flag to the npm install
command4. After installing the prettier
tool, we are creating a
.prettierrc
file to store the configuration of the prettier
tool. In this file, we can specify the configuration of
the prettier
tool such as the maximum line width, the tab width, and the trailing comma. Lets add the following
configuration to the .prettierrc
file:
{
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"bracketSameLine": true,
"arrowParens": "always",
"endOfLine": "auto"
}
Now we can install the tools for the TypeScript development. We need to install the typescript
package to compile our
TypeScript code to JavaScript code. Also, we need to install the tsx
package to run our TypeScript code. In addition to
these packages, we need to install the @types/node
package to use the Node.js types in our TypeScript code. @types/node
package contains the type definitions of the Node.js core modules.
We can create a tsconfig.json
file by running the npx tsc --init
command. This command creates a tsconfig.json
file
with default values. We will update this file later. tsconfig.json
file is used to store the configuration of the
TypeScript compiler. In this file, we can specify the target version of the JavaScript, the module system, the output
directory, and many other options.
As last step, we are creating a README.md
file to store the documentation of the project. We will not add any content
to this file in this book. But, you can add any content you want to this file. README.md
file is an important piece
of any project. It is the first point of contact for the users and the developers of the project. For this reason, please
try to write a good documentation for your project.
In order to define aliases for common commands, we can add the following lines to the scripts
field of the
package.json
:
"main": "src/index.ts",
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "tsx src/index.ts",
"dev": "tsx --watch src/index.ts"
},
If you are not familiar with the async programming, please look at NOT READY
If you do not understand the script please look at Operating Systems Chapter
If you do not know what is git, please look at Version Control Chapter
If you are not familiar with the npm, please look at Programming Language Tools Chapter
Iteration 1. Implementing without any structure (a.k.a. spaghetti code)
The plan is simple:
- Get command line arguments.
- Check the existence of the todo list file. If it does not exist, create it.
- Parse the command line arguments and decide which operation to perform.
- Read the todo list file and parse it.
- Perform the operation.
- Write the updated list back to the file. (If it is necessary)
The first thing that we will need to do is getting arguments from the command line. We can use the process.argv
array
to get the command line arguments. The process.argv
array contains the command line arguments that are passed to the
Node.js process. The first element of the process.argv
array is the path to the Node.js executable and the second
element is the path to the JavaScript file that is executed. The rest of the elements are the command line arguments
that are passed to the Node.js process.
After getting the command line arguments, we can use fs.promises.stat
function to checks the existence of the
todo list file. If the file does not exist, we can create it by using the fs.promises.writeFile
function.
Then, we can simply use a switch-case statement to decide which operation to perform. In each case, we can execute different statements to perform the operation. I will not explain each line of the code in detail because instead it is an exercise for you.
Also in order to use async/await syntax, we need to define an async function. An async function is a function that
returns a promise, in other words, it is a function that produces a result which will be completed in the future. We
can use the async
keyword before the function definition to define an async function. We can use the await
keyword
inside the async function to wait for the completion of the promise. Node.js does not provide top-level await support
yet. This means that we cannot use the await
keyword outside of an async function. For this reason, we need to define
an async function and we need to call it in the top-level code. For this reason, we will define an async function named
main
and we will call it in the top-level code.
Lets implement the first version of our todo list application by adding the following lines to the src/index.ts
file:
#!/usr/bin/env -S npx tsx
import process from 'process';
import fs from 'fs';
import crypto from 'crypto';
// Define what a todo looks like
interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
}
async function main() {
// process.argv is provided by Node.js and it is an array that contains the command line arguments
// The first element is the path to the Node.js executable
// The second element is the path to the script file
// The third element is the subcommand
// The rest of the elements are the arguments
// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;
// If the todo list file does not exist, create it
try {
await fs.promises.stat('todos.json');
} catch {
await fs.promises.writeFile('todos.json', '[]');
}
// Handle the subcommands
switch (subcommand) {
case 'add': {
const [title, description] = args;
// Read todo list file and parse it
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
// Validate the input
if (!title || typeof title !== 'string' || title.length < 2 || title.length > 255) {
console.log('Title is required and its length must be between 2 and 255 characters');
process.exit(1);
}
if (description && (typeof description !== 'string' || description.length > 4096)) {
console.log('Description length must be less than 4096 characters');
process.exit(1);
}
// Generate a new todo
const id = crypto.randomBytes(4).toString('hex');
const newTodo: Todo = {
id: id,
title,
description,
completed: false,
};
// Add the new todo to the list
todoList.push(newTodo);
// Write the updated list back to the file
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile('todos.json', updatedFileContent);
console.log('New todo added');
break;
}
case 'list': {
// Read todo list file and parse it
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
// Print the list of todos
for (const todo of todoList) {
const status = todo.completed ? 'X' : ' ';
console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
if (todo.description) console.log(`\t${todo.description}`);
}
break;
}
case 'done': {
const [id] = args;
// Read todo list file and parse it
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === args[0]);
if (!todo) {
console.log('Todo not found');
process.exit(1);
}
// Mark the todo as completed
todo.completed = true;
// Write the updated list back to the file
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile('todos.json', updatedFileContent);
// Print the message
console.log('Todo marked as done');
break;
}
case 'undone': {
const [id] = args;
// Read todo list file and parse it
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === args[0]);
if (!todo) {
console.log('Todo not found');
process.exit(1);
}
// Mark the todo as not completed
todo.completed = false;
// Write the updated list back to the file
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile('todos.json', updatedFileContent);
// Print the message
console.log('Todo marked as undone');
break;
}
case 'delete': {
const [id] = args;
// Read todo list file and parse it
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
// Delete the todo with the given id
const index = todoList.findIndex((todo) => todo.id === args[0]);
if (index === -1) {
console.log('Todo not found');
process.exit(1);
}
todoList.splice(index, 1);
// Write the updated list back to the file
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile('todos.json', updatedFileContent);
// Print the message
console.log('Todo deleted');
break;
}
default:
// Print help messages
console.log(`Unknown subcommand`);
console.log(`Usage: tdr <subcommand> [args]`);
console.log(`Subcommands: add, list, done, undone, delete`);
// Exit with an error code
process.exit(1);
}
}
main();
And we can test the application by running the following commands:
# Add a new todo
npm run start add "A sample task description" "This is a sample task description"
npm run start add "Another task" "This is another task"
# List the todos
npm run start list
# Mark a todo as done
npm run start done #PutYourTodoIdHere#
# Mark a todo as undone
npm run start undone #PutYourTodoIdHere#
# Delete a todo
npm run start delete #PutYourTodoIdHere#
As you can see we are using npm run start
command to run the application. This command runs the start
script that is
defined in the package.json
file. The start
script runs the tsx src/index.ts
command. In order to run our script,
with a tdr
command, we need to define a bin
field in the package.json
file. Lets add the following lines to the
package.json
file:
"bin": {
"tdr": "src/index.ts"
},
This part of the code tells the npm that this package provides a command line utility named tdr
and it is located in
the src/index.ts
file. After adding this line executing npm install
command will create a directory under the
node_modules
directory and it will create a symbolic link to the src/index.ts
file. This way, we can run the
tdr
command from the command line by using the npx tdr
command. We need to use the npx
command because the tdr
command is not installed globally and therefore thh node_modules/.bin
directory is not in the PATH
environment
variable. The npx
command is used to run the commands that are located in the node_modules/.bin
directory. Lets test
the application by running npx tdr list
command.
Also, the first line of the src/index.ts
file is a shebang line. A shebang line is a special line that is used to
specify the interpreter that should be used to run the script. In our case, we are using tsx to run the TypeScript code.
For this reason, we are using the #!/usr/bin/env -S npx tsx
shebang line. This line tells the operating system to use
the provided command to run the script. We must provide shebang line in order to run the script directly from the command
line because operating system does not know how to run a script instead it is only capable of running binary files which
contains machine code for the processor. By providing a shebang line, actually we are delegating the execution of the
script to the provided command. In our case, we are delegating the execution of the script to the tsx
command.
Right now, we have a "working" todo list application but this is not the best implementation. Lets improve it.
Iteration 2. Implementing with functions and classes (semi-structured code)
In the previous implementation, we did not utilize functions. Without changing the requirements and the file structure of the todo list, lets refactor the code to use functions.
Functions are the building blocks of the software. They are used to encapsulate the logic and to make the code more
readable and maintainable. In our case, we can define different functions for each operation. For example, we can define
a listTodos
function to list all of the todos, a addTodo
function to add a new todo, a markTodoAsDone
function to
mark a todo as done, a markTodoAsUndone
function to mark a todo as undone, and a deleteTodo
function to delete a todo.
Also we can separate the functions to different files. This way, we can create a more modular and maintainable codebase.
It is a good way to group related functions in the same file. This way, we can easily find the related functions and
we can easily reuse them in different parts of the codebase. For this reason, we will use two different files: index.ts
and todo.ts
. We will move the todo related functions to the todo.ts
file and we will import them in the index.ts
#!/usr/bin/env -S npx tsx
import process from 'process';
import * as todoService from './TodoService';
async function main() {
// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;
switch (subcommand) {
case 'add': {
const [title, description] = args;
await todoService.addTodo(title, description);
}
case 'list': {
await todoService.listTodos();
break;
}
case 'done': {
const [id] = args;
await todoService.markTodoAsDone(id);
break;
}
case 'undone': {
const [id] = args;
await todoService.markTodoAsUndone(id);
break;
}
case 'delete': {
const [id] = args;
await todoService.deleteTodo(id);
break;
}
default:
// Print help messages
console.log(`Unknown subcommand`);
console.log(`Usage: tdr <subcommand> [args]`);
console.log(`Subcommands: add, list, done, undone, delete`);
// Exit with an error code
process.exit(1);
}
}
main();
As you can see, by separating the functions to different files, our index.ts
file becomes much more readable. Also,
its responsibility is reduced. It is now only responsible for parsing the command line arguments and calling the related
functions. Responsibility, this is the most important thing that you need to understand in this chapter.
When you separate your code into different pieces, you should ask this questions to yourself
What is the responsibility of this file (or this organization unit / module)?
If a file carries more than one responsibility, you should try to split responsibilities into different files. This way,
you can create a more maintainable and readable codebase. Therefore as a rule of thumb;
Single Responsibility Principle (SRP)
A class, function or module should have only one reason to change.
Lets look at TodoService.ts
file:
import fs from 'fs';
import crypto from 'crypto';
// Define what a todo looks like
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
}
// Read todo list file and parse it
async function readTodos(): Promise<Todo[]> {
try {
await fs.promises.stat('todos.json');
} catch {
await fs.promises.writeFile('todos.json', '[]');
}
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
return todoList;
}
// Write the updated list back to the file
async function writeTodos(todoList: Todo[]): Promise<void> {
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile('todos.json', updatedFileContent);
}
export async function addTodo(title: string, description: string): Promise<void> {
const todoList = await readTodos();
// Validate the input
if (!title || typeof title !== 'string' || title.length < 2 || title.length > 255) {
console.log('Title is required and its length must be between 2 and 255 characters');
process.exit(1);
}
if (description && (typeof description !== 'string' || description.length > 4096)) {
console.log('Description length must be less than 4096 characters');
process.exit(1);
}
// Generate a new todo
const id = crypto.randomBytes(4).toString('hex');
const newTodo: Todo = {
id: id,
title,
description,
completed: false,
};
// Add the new todo to the list
todoList.push(newTodo);
writeTodos(todoList);
console.log('New todo added');
}
export async function listTodos() {
const todoList = await readTodos();
// Print the list of todos
for (const todo of todoList) {
const status = todo.completed ? 'X' : ' ';
console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
if (todo.description) console.log(`\t${todo.description}`);
}
}
export async function markTodoAsDone(id: string) {
const todoList = await readTodos();
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === id);
if (!todo) {
console.log('Todo not found');
process.exit(1);
}
// Mark the todo as completed
todo.completed = true;
writeTodos(todoList);
// Print the message
console.log('Todo marked as done');
}
export async function markTodoAsUndone(id: string) {
const todoList = await readTodos();
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === id);
if (!todo) {
console.log('Todo not found');
process.exit(1);
}
// Mark the todo as not completed
todo.completed = false;
writeTodos(todoList);
// Print the message
console.log('Todo marked as undone');
}
export async function deleteTodo(id: string) {
const todoList = await readTodos();
// Delete the todo with the given id
const index = todoList.findIndex((todo) => todo.id === id);
if (index === -1) {
console.log('Todo not found');
process.exit(1);
}
todoList.splice(index, 1);
writeTodos(todoList);
// Print the message
console.log('Todo deleted');
}
As we can group related functions in the same file, we can also group related functions in a class (Although, there is
no so much difference between a class and a module in this example). Classes may be used to group related functions.
Actually classes are more than that. They are used to group related data and functions. In our case, we can define a
TodoService
class to group the todo related functions. Right now, we will not store any data in the TodoService
instances. However, we will add a state to the TodoService
class in the next chapter. For this reason, we will
use classes to group related functions. Let me show the code:
#!/usr/bin/env -S npx tsx
import process from 'process';
import TodoService from './TodoService';
const todoService = new TodoService();
async function main() {
// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;
switch (subcommand) {
case 'add': {
const [title, description] = args;
await todoService.addTodo(title, description);
}
case 'list': {
await todoService.listTodos();
break;
}
case 'done': {
const [id] = args;
await todoService.markTodoAsDone(id);
break;
}
case 'undone': {
const [id] = args;
await todoService.markTodoAsUndone(id);
break;
}
case 'delete': {
const [id] = args;
await todoService.deleteTodo(id);
break;
}
default:
// Print help messages
console.log(`Unknown subcommand`);
console.log(`Usage: tdr <subcommand> [args]`);
console.log(`Subcommands: add, list, done, undone, delete`);
// Exit with an error code
process.exit(1);
}
}
main();
import fs from 'fs';
import crypto from 'crypto';
// Define what a todo looks like
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
}
// Define the TodoService class
class TodoService {
fileName = 'todos.json';
constructor() {}
private async readTodos(): Promise<Todo[]> {
try {
await fs.promises.stat('todos.json');
} catch {
await fs.promises.writeFile('todos.json', '[]');
}
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
return todoList;
}
private async writeTodos(todoList: Todo[]): Promise<void> {
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile(this.fileName, updatedFileContent);
}
public async addTodo(title: string, description: string): Promise<void> {
const todoList = await this.readTodos();
// Validate the input
if (!title || typeof title !== 'string' || title.length < 2 || title.length > 255) {
console.log('Title is required and its length must be between 2 and 255 characters');
process.exit(1);
}
if (description && (typeof description !== 'string' || description.length > 4096)) {
console.log('Description length must be less than 4096 characters');
process.exit(1);
}
// Generate a new todo
const id = crypto.randomBytes(4).toString('hex');
const newTodo: Todo = {
id: id,
title,
description,
completed: false,
};
// Add the new todo to the list
todoList.push(newTodo);
await this.writeTodos(todoList);
console.log('New todo added');
}
public async listTodos(): Promise<void> {
const todoList = await this.readTodos();
// Print the list of todos
for (const todo of todoList) {
const status = todo.completed ? 'X' : ' ';
console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
if (todo.description) console.log(`\t${todo.description}`);
}
}
public async markTodoAsDone(id: string): Promise<void> {
const todoList = await this.readTodos();
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === id);
if (!todo) {
console.log('Todo not found');
process.exit(1);
}
// Mark the todo as completed
todo.completed = true;
await this.writeTodos(todoList);
// Print the message
console.log('Todo marked as done');
}
public async markTodoAsUndone(id: string): Promise<void> {
const todoList = await this.readTodos();
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === id);
if (!todo) {
console.log('Todo not found');
process.exit(1);
}
// Mark the todo as not completed
todo.completed = false;
await this.writeTodos(todoList);
// Print the message
console.log('Todo marked as undone');
}
public async deleteTodo(id: string): Promise<void> {
const todoList = await this.readTodos();
// Delete the todo with the given id
const index = todoList.findIndex((todo) => todo.id === id);
if (index === -1) {
console.log('Todo not found');
process.exit(1);
}
todoList.splice(index, 1);
await this.writeTodos(todoList);
// Print the message
console.log('Todo deleted');
}
}
export default TodoService;
Iteration 3. Layered Architecture
Right now, we have semi-structured code. It is semi-structured because it does not have a well-defined boundaries between the layers. In order to create more structured code, we need to define the parts of our application by providing a definition for each part. Lets try to define the parts of our application:
index.ts
: This is the entry point of our application. It is responsible for parsing the command line arguments and calling the correct functions or methods.TodoService.ts
: This is the service layer of our application. It is responsible for the business logic of the todo list. It is responsible for adding, listing, marking as done, marking as undone, and deleting the todos.
According to these definitions, we can say that TodoService
does not depend on index.ts
but index.ts
depends on
TodoService
. Therefore, we have two parts and one part depends another while the dependent part is independent.
Let me give an anology for this. We can see our project as a cake and parts of the project as layers of the cake.
Dependent part index.ts
is located at the top of the independent part TodoService.ts
. If underlying part of the
cake layer is not ready yet, you cannot put the upper layer on it. Therefore, the existence of upper layer depends on
the existence of the underlying layer.
Therefore, our current implementation has two layers right now. The first layer is the index.ts
file and the second
layer is the TodoService.ts
file. A careful reader may notice that our TodoService
does not have a single
responsibility because it handles;
- The business logic of the todo list.
- The file operations (reading and writing the JSON file).
- Printing the information to the console.
And, this conflicts with the Single Responsibility Principle. Therefore, we need to redifine our layers and arrange our files according to the new layers. Lets try to redefine our layers in a more rigorous way:
- Presentation Layer: Handles all of the user interactions. (Parsing the command line arguments and printing the results)
- Service Layer: Validates and runs all the business logic of the application.
- Data Access Layer: Stores and retries the data that is produced by the service layer.
According to these definitions, lets arrange our files:
- Presentation Layer: We should handle all of the operations that is related to user activity in
index.ts
file. Therefore, we need to handle all command line related operations in theindex.ts
file. - Service Layer: We should handle all of the business logic of the todo list in the
TodoService.ts
file and we should only handle bussiness logic in this layer. Therefore, we need to move console logging operations to theindex.ts
file and data access related operations to the Data Access Layer. - Data Access Layer: We should handle all of the file operations in the
TodoRepository.ts
file and again we should only handle file operations in this layer. We call itTodoRepository
because by definition (non-techical) a repository is a place where things are stored.
Lets refactor them by starting from TodoRepository.ts
:
import fs from 'fs';
export interface Todo {
id: string;
title: string;
description: string;
completed: boolean;
}
class TodoRepository {
fileName: string = 'todos.json';
constructor() {}
public async readTodos(): Promise<Todo[]> {
try {
await fs.promises.stat('todos.json');
} catch {
await fs.promises.writeFile('todos.json', '[]');
}
const fileConent = await fs.promises.readFile('todos.json', 'utf-8');
const todoList = JSON.parse(fileConent) as Todo[];
return todoList;
}
public async writeTodos(todoList: Todo[]): Promise<void> {
const updatedFileContent = JSON.stringify(todoList, null, 2);
await fs.promises.writeFile(this.fileName, updatedFileContent);
}
}
export default TodoRepository;
Our repository is so simple. Lets move to the TodoService.ts
file:
import TodoRepository, { Todo } from './TodoRepository';
import crypto from 'crypto';
// Define the TodoService class
class TodoService {
repository: TodoRepository;
constructor() {
this.repository = new TodoRepository();
}
public async addTodo(title: string, description: string): Promise<void> {
const todoList = await this.repository.readTodos();
// Validate the input
if (!title || typeof title !== 'string' || title.length < 2 || title.length > 255) throw new Error('Title is required and its length must be between 2 and 255 characters');
if (description && (typeof description !== 'string' || description.length > 4096)) throw new Error('Description length must be less than 4096 characters');
// Generate a new todo
const id = crypto.randomBytes(4).toString('hex');
const newTodo: Todo = {
id: id,
title,
description,
completed: false,
};
// Add the new todo to the list
todoList.push(newTodo);
await this.repository.writeTodos(todoList);
}
public async listTodos(): Promise<Todo[]> {
const todoList = await this.repository.readTodos();
return todoList;
}
public async markTodoAsDone(id: string): Promise<void> {
const todoList = await this.repository.readTodos();
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === id);
if (!todo) throw new Error('Todo not found');
// Mark the todo as completed
todo.completed = true;
await this.repository.writeTodos(todoList);
}
public async markTodoAsUndone(id: string): Promise<void> {
const todoList = await this.repository.readTodos();
// Find the todo with the given id
const todo = todoList.find((todo) => todo.id === id);
if (!todo) throw new Error('Todo not found');
// Mark the todo as not completed
todo.completed = false;
await this.repository.writeTodos(todoList);
}
public async deleteTodo(id: string): Promise<void> {
const todoList = await this.repository.readTodos();
// Delete the todo with the given id
const index = todoList.findIndex((todo) => todo.id === id);
if (index === -1) throw new Error('Todo not found');
todoList.splice(index, 1);
await this.repository.writeTodos(todoList);
}
}
export default TodoService;
As you can see, we do not have any methods for file operations in the TodoService
class. Instead, we are creating
an instance of the TodoRepository
class and we are using it to read and write the JSON file. This way, we are
separating the file operations from the business logic. Also, another improvement that we made is removing the console
logging operations from the TodoService
class. Instead of printing the information to the console directly in the
TodoService
class, we are returning the necessary information needed to print to the console to the caller. This way,
our TodoService
class becomes much more cleaner and maintainable. Lets refactor the index.ts
file:
#!/usr/bin/env -S npx tsx
import process from 'process';
import TodoService from './TodoService';
const todoService = new TodoService();
async function main() {
// Get command line arguments
const [program, script, subcommand, ...args] = process.argv;
switch (subcommand) {
case 'add': {
const [title, description] = args;
await todoService.addTodo(title, description);
console.log('New todo added');
}
case 'list': {
const todoList = await todoService.listTodos();
// Print the list of todos
for (const todo of todoList) {
const status = todo.completed ? 'X' : ' ';
console.log(`- [${status}] (id: ${todo.id}) ${todo.title}`);
if (todo.description) console.log(`\t${todo.description}`);
}
break;
}
case 'done': {
try {
const [id] = args;
await todoService.markTodoAsDone(id);
console.log('Todo marked as done');
break;
} catch (error) {
if (error instanceof Error) console.log(error.message);
process.exit(1);
}
}
case 'undone': {
try {
const [id] = args;
await todoService.markTodoAsUndone(id);
console.log('Todo marked as undone');
break;
} catch (error) {
if (error instanceof Error) console.log(error.message);
process.exit(1);
}
}
case 'delete': {
try {
const [id] = args;
await todoService.deleteTodo(id);
console.log('Todo deleted');
break;
} catch (error) {
if (error instanceof Error) console.log(error.message);
process.exit(1);
}
}
default:
// Print help messages
console.log(`Unknown subcommand`);
console.log(`Usage: tdr <subcommand> [args]`);
console.log(`Subcommands: add, list, done, undone, delete`);
// Exit with an error code
process.exit(1);
}
}
main();
Summary
In this chapter, you should have learned;
- What is a command line interface application?
- What is a functional and non-functional requirement?
- What is layered architecture?
- What is the Single Responsibility Principle?
- How to create a new project from scratch
- How to implement a simple command line interface application
- How to define functional and non-functional requirements
- How to implement a simple TODO list application
Also, you should refresh your knowledge about;
- How to serialize and deserialize JSON data
- How to use functions and classes in TypeScript
- How to use promises in Node.js
There are two important things that you need to get from this chapter:
- Single Responsibility Principle: A class, function or module should have only one reason to change.
- Layered Architecture: A software architecture that divides the software into different layers. Each layer has a specific responsibility and each layer depends on the underlying layer.
Exercises
- Look at prettier configuration parameters and try to understand meaning of each parameter.
- Implement each version of the TODO list application by yourself.