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 runningnpm install --global typescript
and then creatingtsconfig.json
withtsc --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";
= require.main === module;
const isExecutedAsScript if (isExecutedAsScript) {
= buildCmd();
const cmd
.parse(process.argv);
cmd }
Where
// src/cmd.ts
import { Command } from "commander";
import buildSayCmd from "./say";
= "Hello!";
const HELLO
default (): Command => {
export = new Command()
const command .option("-g, --greet", `Say ${HELLO}`, false)
.addCommand(buildSayCmd())
.addHelpCommand()
.showHelpAfterError();
.action((options) => {
commandif (options.greet) {
console.log(HELLO);
return
}
.help();
command;
})
;
return command; }
and
// src/say.ts
import { Command } from "commander";
default (): Command =>
export 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
.exports = {
moduletestMatch: ['**/*.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(() => {
.spyOn(process, "exit").mockImplementation();
jest.spyOn(console, "log").mockImplementation();
jest;
})
= (...args: string[]) => ["node", "cmd", ...args];
const buildArgs
describe("Say subcommand", () => {
it("Should say 'Konnichiwa!' when passed 'Konnichiwa!'", () => {
= buildCmd();
const cmd .parse(buildArgs("say", "Konnichiwa!"));
cmd
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. 🚀