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:

  1. As a user, I should be able to list all of the todos that is created before.
  2. 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.
  3. As a user, I should be able to mark a todo as done.
  4. As a user, I should be able to mark a todo as undone.
  5. 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:

  1. The application should be implemented in TypeScript.
  2. The application should use asynchronous operations instead of synchronous (blocking) operations.
  3. As a user, I should be able to add a todo by giving this command: tdr add "A sample task" "A sample task description".
  4. As a user, I should be able to list all of the todos by giving this command: tdr list.
  5. As a user, I should be able to mark a todo as done by giving this command: tdr done id-of-todo.
  6. As a user, I should be able to mark a todo as undone by giving this command: tdr undone id-of-todo.
  7. As a user, I should be able to delete a todo by giving this command: tdr delete id-of-todo.
  8. 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"
  },
1

If you are not familiar with the async programming, please look at NOT READY

2

If you do not understand the script please look at Operating Systems Chapter

3

If you do not know what is git, please look at Version Control Chapter

4

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:

  1. Get command line arguments.
  2. Check the existence of the todo list file. If it does not exist, create it.
  3. Parse the command line arguments and decide which operation to perform.
  4. Read the todo list file and parse it.
  5. Perform the operation.
  6. 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.

Cake

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 the index.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 the index.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 it TodoRepository 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:

  1. Single Responsibility Principle: A class, function or module should have only one reason to change.
  2. 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

  1. Look at prettier configuration parameters and try to understand meaning of each parameter.
  2. Implement each version of the TODO list application by yourself.