M Logo
๐Ÿ“ TypeScript from Zero ยท Part 33 of 50

TypeScript: Build a CLI with tsc, the bin Field and npm link (Shebang Included)

November 6, 2026

Welcome to episode 33 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! This is the last episode of the task tracker. So far we ran it with node and a file name. A real tool has a name, like git or npm, and runs from anywhere. Today we build it, and install it. We are in the tasks project, in the cli file. Every code block below is the exact code and output from the video, ready to copy.

Setup

We start in the terminal and open the project in VS Code:

bash
cd tasks
code .

1. The CLI

First we put the pieces together. We replace the cli file with the full program. It loads the tasks, and reads the command. Add saves a new task, list prints all of them, and anything else shows how to use the tool. We try it with node.

src/cli.ts:

ts
import { parseArgs } from "node:util";
import { describe } from "./task.ts";
import type { Task } from "./task.ts";
import { loadTasks, saveTasks } from "./store.ts";

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  allowPositionals: true,
  options: {
    title: { type: "string", short: "t" },
  },
});

const tasks = await loadTasks();
const command = positionals[0];

if (command === "add" && values.title !== undefined) {
  const task: Task = { id: tasks.length + 1, title: values.title, status: "todo" };
  await saveTasks([...tasks, task]);
  console.log("Added:", describe(task));
} else if (command === "list") {
  for (const task of tasks) {
    console.log(describe(task));
  }
} else {
  console.log("Usage: tasks add --title <text> | tasks list");
}
text
$ node src/cli.ts add --title "Buy milk"
Added: #1 Buy milk (not started)
text
$ node src/cli.ts list
#1 Buy milk (not started)

2. Build

Node can run our files directly, but a tool that we share should be plain JavaScript. tsc, without any file name, builds the whole project into the dist folder, as set in the T S config file. In dist we find the finished files. And look at the first lines of cli.js: the imports end with.js, not.ts. That is the rewrite option at work.

bash
$ npx tsc
text
$ ls dist/*.js
dist/cli.js
dist/demo.js
dist/errors.js
dist/index.js
dist/show.js
dist/stats.js
dist/store.js
dist/task.js
dist/update.js
text
$ head -4 dist/cli.js
import { parseArgs } from "node:util";
import { describe } from "./task.js";
import { loadTasks, saveTasks } from "./store.js";
const { values, positionals } = parseArgs({

3. bin

To get a command, we add a bin field to the package file. It says: the command tasks runs the file dist slash cli.js. npm link then installs the command on this computer, using a link to our folder. We try it. And it fails with strange errors. The system does not know that this is a Node program.

bash
$ npm pkg set bin.tasks=dist/cli.js
text
$ npm link

added 1 package, and audited 3 packages in 85ms

found 0 vulnerabilities
text
$ tasks list
/opt/node26/bin/tasks: 1: import: not found
/opt/node26/bin/tasks: 2: import: not found
/opt/node26/bin/tasks: 3: import: not found
/opt/node26/bin/tasks: 4: Syntax error: "(" unexpected

4. Shebang

The first line of a program tells the system which program should run it. It is called a shebang: hash, exclamation mark, then env node. We add it at the top of the source file, build again, and tsc keeps it in the output. Now the command works from anywhere. We add a task, and list them.

src/cli.ts:

ts
#!/usr/bin/env node
import { parseArgs } from "node:util";
import { describe } from "./task.ts";
import type { Task } from "./task.ts";
import { loadTasks, saveTasks } from "./store.ts";

const { values, positionals } = parseArgs({
  args: process.argv.slice(2),
  allowPositionals: true,
  options: {
    title: { type: "string", short: "t" },
  },
});

const tasks = await loadTasks();
const command = positionals[0];

if (command === "add" && values.title !== undefined) {
  const task: Task = { id: tasks.length + 1, title: values.title, status: "todo" };
  await saveTasks([...tasks, task]);
  console.log("Added:", describe(task));
} else if (command === "list") {
  for (const task of tasks) {
    console.log(describe(task));
  }
} else {
  console.log("Usage: tasks add --title <text> | tasks list");
}
bash
$ npx tsc
text
$ tasks list
#1 Buy milk (not started)
text
$ tasks add --title "Ship it"
Added: #2 Ship it (not started)

Recap

Today: the full tool, tsc builds the project into dist, the bin field, the shebang line, and npm link. That finishes Part 3, and our task tracker. Next episode: Part 4, where we check data with a library called Zod.

ยฉ 2026 All Rights Reserved By Mahmudul Alam