This tutorial builds a small, typed command-line program with Node.js, Commander.js, and TypeScript. You’ll add a command, arguments, options, input validation, generated help, nonzero error exit codes, and scripts to run and compile the project locally.
Key Takeaways
- Commander.js defines commands, required arguments, options, and generated help.
- A custom option parser can validate input before the command action runs.
- Commander reports invalid CLI input with a nonzero exit code; check it from the shell when diagnosing failures.
- TypeScript compiles the CLI to JavaScript, which Node.js runs from the build directory.
1. Create the project
Use Node.js 20 or later for this setup. Start an npm project and install Commander.js, TypeScript, Node.js type definitions, and tsx for running TypeScript during development:
mkdir hello-cli
cd hello-cli
npm init -y
npm install commander
npm install --save-dev typescript @types/node tsx
Create this structure:
hello-cli/
├── src/
│ └── index.ts
├── package.json
└── tsconfig.json
Update package.json. npm will add the installed dependencies to the file when you run the installation commands above; keep those dependency entries when replacing or editing the rest of the file.
{
"name": "hello-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"hello-cli": "./dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
}
}
The bin entry declares the command name and the compiled file that should run when the package is linked or installed. The TypeScript source uses a Node.js shebang, which TypeScript preserves in the emitted JavaScript.
Set up TypeScript with Node's modern module resolution:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
With type set to module, this config treats the project as an ES module package. NodeNext makes TypeScript follow Node.js module-resolution rules.
2. Define a command, argument, and options
Create src/index.ts:
#!/usr/bin/env node
import { Command, InvalidArgumentError } from "commander";
interface GreetOptions {
count: number;
shout?: boolean;
}
function parsePositiveInteger(value: string): number {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new InvalidArgumentError("count must be a positive integer");
}
return parsed;
}
const program = new Command();
program
.name("hello-cli")
.description("Print a greeting")
.version("1.0.0")
.showHelpAfterError();
program
.command("greet <name>")
.description("Greet someone")
.option("-c, --count <number>", "number of greetings", parsePositiveInteger, 1)
.option("--shout", "print the greeting in uppercase")
.action((name: string, options: GreetOptions) => {
const greeting = `Hello, ${name}!`;
const output = options.shout ? greeting.toUpperCase() : greeting;
for (let i = 0; i < options.count; i += 1) {
console.log(output);
}
});
program.parse();
greet <name> declares a required positional argument. Commander displays an error if it is missing. --count <number> takes a value, passes it to parsePositiveInteger, and defaults to 1. The parser rejects decimals, unsafe integers, zero, negative numbers, and non-numeric values by throwing InvalidArgumentError. Commander presents that as a CLI input error rather than letting an invalid count reach the action.
--shout is a boolean option. Commander generates help from the command and option definitions, so you do not have to build a help screen by hand. Its default error handling also reports malformed input and exits with a nonzero status.
3. Run the CLI locally
Run TypeScript directly during development with tsx:
npm run dev -- greet Ada
Expected output:
Hello, Ada!
Add options to repeat the greeting and capitalize it:
npm run dev -- greet Ada --count 2 --shout
Expected output:
HELLO, ADA!
HELLO, ADA!
The -- separates npm's arguments from the arguments intended for your CLI. Without that separator, npm may treat the CLI arguments as its own.
4. Inspect help and errors
Ask the root command for its help:
npm run dev -- --help
To see the arguments and options for greet, request that command's help:
npm run dev -- greet --help
Try an invalid count:
npm run dev -- greet Ada --count 0
Commander prints an error and, because of showHelpAfterError(), follows it with help. The process exits unsuccessfully. Check the status in a POSIX-compatible shell with:
npm run dev -- greet Ada --count 0
echo $?
A nonzero status indicates failure; the exact value can depend on how a command is run. In scripts and automation, test for zero versus nonzero rather than relying on a particular nonzero value. If you later add your own operations that can fail, report the problem to standard error and set process.exitCode to a nonzero value instead of silently swallowing the error.
5. Build and run the compiled CLI
Compile the TypeScript source, then run the emitted JavaScript with Node.js:
npm run build
npm start -- greet Ada --count 2
The build writes dist/index.js. To try the declared hello-cli command on your machine without publishing the package, run npm link from the project directory. npm links the package's bin command; you can then invoke hello-cli greet Ada. Run npm unlink -g hello-cli when you want to remove that global link.
Common Mistakes
- Using
parseIntwithout checking the whole value:parseInt("3cats", 10)returns3. Converting withNumberand checkingNumber.isSafeIntegerrejects that partial input. - Forgetting the npm argument separator: Use
npm run dev -- greet Ada, notnpm run dev greet Ada. - Running the build output before building:
npm startexpectsdist/index.js. Runnpm run buildfirst. - Changing to CommonJS without changing the module configuration: This project is configured for ES modules. Keep
type,module, andmoduleResolutionaligned, or deliberately update them together. - Assuming help covers every runtime failure: Generated help documents the CLI interface; it does not replace error handling for work your command performs.
Best Practices
- Validate option values at the CLI boundary, before using them in the command action.
- Give required arguments and options clear descriptions so generated help is useful.
- Keep expected user-input errors readable; avoid exposing stack traces for routine mistakes.
- Use exit status
0for success and a nonzero status for failure so shells and CI jobs can detect errors. - Build and run the compiled output before publishing or distributing the CLI. A development runner alone does not verify that the build artifact works.
Conclusion
Commander.js handles the command-line interface while TypeScript checks and compiles the implementation. With a parser for option validation and a build script, this small project provides a practical foundation for a Node.js CLI that can run locally and be linked as a command.
