Writing Cli Tools With Typescript

Intro

While recently working on a Pandoc filter, I ran the side project of writing the code for the filter executable in TypeScript in order to evaluate JavaScripts’ big brother for command line tool development.

Unsurprisingly, this turned out well or I would probably not be writing this. :) As an offshoot, I decided to write a starter kit for TypeScript CLI tool development which can be found on GitHub and npm. The next few sections will describe the steps to recreate this starter project and provide some insight on the choices I made.

Being a fan of TypeScript I wasn’t all that surprised that I would enjoy writing a CLI tool with it. That being said, I wouldn’t recommend TypeScript for every kind of CLI project.

While the tooling for TypeScript is excellent and you can choose between quite a lot of command line tool libraries, you start slow because of the time invested into setup. Once you get rolling, the benefits of static type checks, type inference, auto-completion and other features will kick in and improve development speed and enjoyment.

The first point — kind of obviously — is due to the nature of TypeScript as a superset of JavaScript. To run TypeScript code, you typically transpile to JavaScript and then execute the JavaScript code with Node.js. (Yes, even ts-node does that.) So, before running a program written in TypeScript, we we need to transpile to JavaScript first. This requires tooling and therefore setup. If you want to get going quickly, Bash or Python (with a nice CLI library such as Click) might be the better choice.

On the plus side, TypeScript is kind of a happy marriage of the dynamic and developer friendly nature of JavaScript, the power of Node.js and the rich Node.js ecosystem with an advanced type system and static type checking as well as great IDE support (at least in VSCode). If you have a moderately large CLI tool project where static guarantees such as type checks do matter but you overall value developer speed over the tool performance, writing a CLI in TypeScript is a very good choice.

Project setup

The first step we take is creating a project directory and then initializing it with npm.

mkdir mycli
cd mycli
npm init

NPM will guide you through the initialization process. Choose src/main.ts. as the entry point. We will create this in a moment.

Next, we install TypeScript and some tools.

npm install --save-dev typescript ts-node @types/node

Particularly

  • tsc the TypeScript compiler,
  • ts-node to directly run TypeScript with just in time (JIT) transpilation to JavaScript, and
  • typings for the Node.js standard library (e.g. console)

With this done, we can already add some scripts to the package.json file created earlier during npm init.

// package.json
{
  // 
  "scripts": {
    "dev": "ts-node src/main.ts",
    "build": "tsc --build"
  },
  // 
}

In order to compile our TypeScript code, we need to give tsc a few hints about our project structure. We can do this by supplying a tsconfig.json file which conveniently can be auto generated using tsc itself:

./node_modules/typescript/bin/tsc --init

ℹ️ Note that this runs the project local tsc. You could alternatively install TypeScript globally by running npm install --global typescript and then creating tsconfig.json with tsc --init.

Verify that the tsconfig.json file was created. E.g. with

$ cat tsconfig.json
{
  "compilerOptions": {
    /* Visit https://aka.ms/tsconfig.json to read more about this file */

    /* Basic Options */
    // "incremental": true,                         /* Enable incremental compilation */
    "target": "es5",                                /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */

    //

    "forceConsistentCasingInFileNames": true        /* Disallow inconsistently-cased references to the same file. */
  }
}

Then set the outDir option to dist/. We’ll store the JavaScript code produced by tsc here.

// tsconfig.json
{
  //
  "outDir": "dist" /* Redirect output structure to the directory. */,
  //
}

If we now add a simple src/main.ts file

echo 'console.log("Hello!")' > src/main.ts

we are already able to run our CLI tool with ts-node using the dev script.

$ npm run dev

> @fkurz/typescript-cliutil-starter@1.0.0 dev
> ts-node src/main.ts

Hello!

To transpile to JavaScript, we then run npm run build.

$ npm run build

> @fkurz/typescript-cliutil-starter@1.0.0 build
> tsc --build

$ ls -la dist
total 40
drwxr-xr-x   7 main  staff   224  2 Aug 21:00 .
drwxr-xr-x  19 main  staff   608  2 Aug 21:00 ..
-rw-r--r--   1 main  staff   218  2 Aug 21:00 main.js

Since dist/main.js is a plain JavaScriptfile we can run it with node

$ node dist/main.js
Hello!

This is not a self-contained executable yet (we still need a Node.js installation to run the CLI tool), but let’s add a command line library, linting and tests first.

Adding bells and whistles

Commander

We now have the least amount of code and configuration to build a TypeScript command line utility. Let’s add some bells and whistles.

First of all, to do the heavy lifting with regards to argument parsing and executing commands, we add the commander package.

npm install --save commander

We now rewrite main.ts to wrap execution of our command line program execution (conditioned on being run as a script rather than imported) which we refactor to the cmd module.

import buildCmd from "./cmd";

const isExecutedAsScript = require.main === module;
if (isExecutedAsScript) {
  const cmd = buildCmd();

  cmd.parse(process.argv);
}

Where

// src/cmd.ts
import { Command } from "commander";
import buildSayCmd from "./say";

const HELLO = "Hello!";

export default (): Command => {
  const command = new Command()
    .option("-g, --greet", `Say ${HELLO}`, false)
    .addCommand(buildSayCmd())
    .addHelpCommand()
    .showHelpAfterError();

  command.action((options) => {
    if (options.greet) {
      console.log(HELLO);
      return
    }

    command.help();
  });

  return command;
};

and

// src/say.ts
import { Command } from "commander";

export default (): Command =>
  new Command()
    .name("say")
    .description("Say the word passed as the first argument")
    .argument("<word>")
    .action((word: string) => console.log(word));

ℹ️ Returning a constructor function instead of a command instance from the module is helpful so that we can reset the command in tests. Commander does not bring a reset button out-of-the-box.

We can run this with our dev script.

$ npm run dev

> @fkurz/typescript-cliutil-starter@1.0.0 dev
> ts-node src/main.ts

Usage: main [options] [command]

Options:
  -g, --greet     Say Hello! (default: false)
  -h, --help      display help for command

Commands:
  say <word>      Say the word passed as the first argument
  help [command]  display help for command

$ npm run dev -- -g

> @fkurz/typescript-cliutil-starter@1.0.0 dev
> ts-node src/main.ts "-g"

Hello!

$ npm run dev -- say 'Konnichiwa!'

> @fkurz/typescript-cliutil-starter@1.0.0 dev
> ts-node src/main.ts "say" "Konnichiwa!"

Konnichiwa!

ℹ️ Note that we need to tell npm to stop parsing arguments with the -- argument so ts-node will receive options such as -h.

Linting

Another topic of concern—especially in larger projects—is code quality. Besides sticking to established coding patterns, we minimally want to add tooling for code linting and formating using for example typescript-eslint and prettier. ESLint will take care of reporting common code smells and potential problems. Prettier will help us maintain a common code layout.

npm install --save-dev @typescript-eslint/eslint-plugin \
    @typescript-eslint/parser\
    eslint \
    eslint-config-prettier \
    eslint-plugin-jsdoc \
    eslint-plugin-prefer-arrow 
npm install --save-dev --save-exact prettier
npm install --save-dev eslint-config-prettier \
    eslint-plugin-prettier

We need an .eslintrc.json file to configure ESLint for TypeScript parsing and to integrate Prettier. A simple configuration file is shown below

// eslintrc.json
{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended"
  ]
}

Note that this handles everything that needs to be done to integrate Prettier via the “plugin:prettier/recommended” element in the extends array.

And lastly, we add a script to run the linter with —fix flag to call Prettier for formatting.

"scripts": {
    "dev": "ts-node src/main.ts",
    "lint": "eslint src --ext .ts --fix",
    "build": "tsc --build"
},

Ignore files

Since we don’t want to commit the installed dependencies and the builds, we add them to .gitignore.

echo "/node_modules\n/dist" > .gitignore

Finally, we add and ignore file for ESLint so that dependencies in node_modules/, builds in dist/ , and the shell script in bin/ are being ignored during linting and formating.

// .eslintignore
# don't ever lint node_modules
node_modules
# don't lint build output (make sure it's set to your correct build folder name)
dist
# don't lint shell script
bin

Tests

To test our program, we can simply put tests in the src folder next to the source code of the command and subcommands. By doing that, tests will be transpiled to JavaScript and put into the dist/ folder.

Next, we install a testing library. Jest is a good choice because it requires minimal setup and includes mocks, spies and an assertion library out-of-the-box.

npm install --save-dev jest @types/jest

We need another configuration file for Jest primarily to identify files containing test suites (with testMatch and rootDir) and to set the test runtime environment to Node.js.

// jest.config.js
module.exports = {
  testMatch: ['**/*.test.js'],
  testEnvironment: 'node',
  rootDir: 'dist'
};

This will run all files with suffix.test.js using a Node.js environment

For example, the following test suite checks if the say subcommand works correctly for a sample input.

// say.test.ts
import buildCmd from "./cmd";

beforeEach(() => {
  jest.spyOn(process, "exit").mockImplementation();
  jest.spyOn(console, "log").mockImplementation();
});

const buildArgs = (...args: string[]) => ["node", "cmd", ...args];

describe("Say subcommand", () => {
  it("Should say 'Konnichiwa!' when passed 'Konnichiwa!'", () => {
    const cmd = buildCmd();
    cmd.parse(buildArgs("say", "Konnichiwa!"));

    expect(console.log).toHaveBeenCalledWith("Konnichiwa!");
  });
});

To run tests with Jest, we add another script which simply calls the jest executable.

// package.json
{
  // 
  "scripts": {
    "dev": "ts-node src/main.ts",
    "lint": "eslint src --ext .ts --fix",
    "build": "tsc --build",
    "test": "jest"
  },
  // 
}

Packaging

The last step is to create a stand-alone executable from our linted, tested and transpiled source code that we can run directly from the command line. The major benefit of having a stand-alone executable is that all dependencies are baked into the binary and does not require any additional installation.

We use pkg to do this

npm install --save-dev pkg

To run the packaging step, we add a package script to create the binary for the host machine.

// package.json
{
  //
  "scripts": {
    "dev": "ts-node src/main.ts",
    "lint": "eslint src --ext .ts --fix",
    "build": "tsc --build",
    "test": "jest",
    "package": "pkg dist/main.js --no-bytecode --public-packages '*' --public --target host --output bin/main"
  },
  //
}

After running npm run package (and npm run build before that), we should have an executable bin/main which bundles everything required to run our code and can be called directly.

$ npm run package

> @fkurz/typescript-cliutil-starter@1.0.1 package
> pkg dist/main.js --output bin/main --targets node14

> pkg@5.3.1

$ bin/main
Usage:  [options] [command]

Options:
  -g, --greet  Say Hello! (default: false)
  -h, --help   display help for command

Commands:
  say <word>   Say the word passed as the first argument

That’s it

With the steps above, we now get a linted, tested, stand-alone executable built from TypeScript code. 🚀

That’s all folks

Sources