If you have ever wanted to build a Command Line Interface (short: CLI) in typescript, this post is for you!

Granted, Typescript is not the most conventional language to do this. Maybe you’re better served with Go and Cobra-cli. But in case TS is your go-to language - like it is for me right now - continue reading.

An overview of the tech stack

First, let’s take a brief overview of the tools we’ll be using:

For the basic typescript project, package manager and javascript-runtime, bun has become my favorite. Bun is blazingly-fast, easy to set up, and just makes coding in TS a joy. If you’ve never tried it, this is the perfect opportunity to do so. Make sure you have bun installed, then continue reading.

Typescript is the language of choice. It provides the developer experience that Javascript never had. In the beginning the type-syntax may seem daunting, but usually the best Typescript is just sprinkled in and the rest is inferred.

Now to the real MVP of this post is @clack/prompts. Clack enables you to effortlessly build beautiful command-line apps. With Typescript. Check out their npm page. I’ve taken a liking to their API and general flow.

Lastly, in case our CLI requires some kind of data storage, we’ll use lowdb. Lowdb is a very simple database that runs on .json. The benefits are to have the basic database operations (create, read, update, delete), persistent storage, and the database is easily readable for troubleshooting and general checks. This is optional, but often comes to be used in some way for my CLIs.

Setting up the project & installing dependencies

Let’s get coding!

Open your favorite code editor and terminal. To start with, we need our basic project:

mkdir simple-ts-cli
cd simple-ts-cli
bun init # run through the bun CLI - for 'entry point' set src/main.ts

We’ll use a src/ directory for all our code. This way we keep the project root clean.

Bun already created a tsconfig.json for us with awesome defaults!

Next, we install the dependencies. Since this is a CLI, we don’t really care about them being devDependencies. Just run:

bun add @clack/prompts lowdb

Quality of life: Typescript path alias imports with bun

One thing that’s easy to setup and makes development even nicer is having import path aliases for every file. This allows us to always use absolute imports. Whenever you move code from one file to the other, the imports can be copied as well. No more trying to figure out how many directories to .. up!

In the tsconfig.json we just add:

{
  "compilerOptions": {
    // ...
    "paths": {
      "~/*": ["./src/*"]
}
}

Now we can import from the base of our src/ directory with “~/”!

Quality of life: Prettier & package.json scripts

I don’t like the semicolons at the end of the line, so I use prettier to remove them. This also makes moving lines, cutting and pasting easier. My IDE is already configured to read the prettier file in the root of the project.

Create a .prettierrc in the project root and add this line

semi: false

Last quality of life thing we cannot go without is a simple start script in our package.json to run our CLI.

In the package.json add:

{
  // ...
  "scripts": {
   "start": "bun run src/main.ts"
  },
  // ...
}

Now we can run our CLI with bun start from the root of the project!

File structure and separation of concerns

We’ll write a simple typescript CLI: ask the user some questions and execut business logic depending on the answers.

Our CLI will “remember” the last run and default to these choices. For this we’ll use lowdb as our database.

The main.ts contains the entry point to our CLI and the main flow. Keep it clean to easily follow the overall flow of the app.

The db.ts contains our database code. The type definition for lowdb and some basic exported functions, like reading the database, updating it or cleaning all the data.

Our user interaction is covered in menu.ts. Here we define the different questions and export a function for each menu - if our CLI ends up being more interactive.

Finally, lib.ts is more of a placeholder for more business logic. Everything else will end up here.

This is not a strict requirement, just some pattern that I’ve ended up with. Keep it simple, don’t abstract, unless you have to.

The CLI menu - menu.ts

Create the file src/menu.ts and add this content:

import { group, outro, select, text } from "@clack/prompts"
import type { LastRunConfig } from "~/db"

// We'll get into the lastRunConfig in the next section
export async function mainMenu(lastRunConfig: LastRunConfig) {
  // group() chains multiple prompts in a row
  const choices = await group(
    {
      username: () =>
        // text() is a basic user input prompt
        text({
          message: "Enter your name: ",
          // if there is a last run, use the value from it
          initialValue: lastRunConfig.username,
          // validate user input before continuing
          validate(value) {
            if (!/[a-z]/.test(value))
              // if we return from validate() the user cannot continue
              return "Username should only contain lowercase characters"
          },
        }),
      // results is an object that contains previous answers!
      environment: ({ result }) =>
        // select() with some types for inferred return types
        select<EnvironmentOption[], EnvironmentValue>({
          message: `Select your environment, ${result.username}`,
          initialValue: lastRunConfig.environment,
          options: [
            // we get auto-complete here from our types
            { value: "dev", label: "Dev" },
            { value: "qa", label: "Staging" },
            {
              value: "prod",
              label: "Prod",
              // hints are only shown when option is hovered
              hint: "⚠ You better know what you are doing",
            },
          ],
        }),
    },
    {
      // in case the user interrupts the menu with CTRL + C
      onCancel: () => {
        outro("K, thx, bye")
        // abort the program, no error
        process.exit(0)
      },
    },
  )

  // we could do more validation or reshaping with the choices here
  return choices
}

// --- Types
// I always use this GenericOption for typing selects
export type GenericOption = {
  label?: string | undefined
  hint?: string | undefined
}

// the actual options of our select
export type EnvironmentValue = "dev" | "qa" | "prod"
// some typescript inferrence magic
export type EnvironmentOption = GenericOption &
  Record<"value", EnvironmentValue>

Study this code and the comments. The group() is a sequence of prompts. In the end choices will be an object containing username and environment. This is just a sample, depending on your needs, create new properties in the group().

The Types are something I just copy & paste from project to project. Notice how we pass EnvironmentOption[] (array!) in the select<EnvironmentOption[], EnvironmentValue>({...}). We’re saying: “These are the possible options and in the end I expect one of these Values”.

With this setup our choices variable knows which values it contains. Insanely useful for later switch case statements or other conditional logic.

With results in the callback of a prompt, we can access previous answers. Super handy, we could even add optional prompts:

const choices = await group({
  answer1: () => text({ message: "Whats the secret word?" }),
  answer2: ({ results }) => {
    if (results.answer1 === "valar morgulis") {
      // optional answer2
      return text({ message: "A man needs a name: " })
    }
    // otherwise answer2 will be undefined
    return
  }
})

For more detail about using the @clack/prompts, read their examples.

The last run configuration from DB - db.ts

Our mainMenu() accepts a lastRunConfig object. It’s a nice touch to remember the last time the user ran an app. We’ll use a simple JSON structure to store this data.

Create the src/db.ts and input this:

import { JSONFilePreset } from "lowdb/node"

export type LastRunConfig = {
  username: string
  environment: "qa" | "dev" | "prod"
}
type Data = {
  lastRunConfig: LastRunConfig
}

const defaultData: Data = {
  lastRunConfig: {
    username: "",
    environment: "dev",
  },
}
const db = await JSONFilePreset<Data>("db.json", defaultData)

export async function updateLastRunConfig(config: LastRunConfig) {
  db.data.lastRunConfig = config
  await db.write()
}

export async function getLastRunConfig() {
  await db.read()
  return db.data.lastRunConfig
}

This code uses the db.json file as our database. We export a function to add a new lastRunConfig and another to get the lastRunConfig.

Bringing it all together - main.ts

Let’s put these pieces together. In the main.ts we write:

import { mainMenu } from "~/menu"
import { getLastRunConfig } from "~/db"
import { outro } from "@clack/prompts"

const lastRunConfig = await getLastRunConfig()
const choices = await mainMenu(lastRunConfig)
await updateLastRunConfig(choices)

outro(`Great choices:

Username - ${choices.username}
Environment - ${choices.environment}`)

Ahh, how simple and beautiful. This code explains itself!

Run the script twice to see if the lastRunConfig actually works.

outro() is another function from @clack/prompts to say a final word of “Goodbye!” to the user.

Of course in a real world app this would only be the beginning. After asking the user some questions, we would actually do something with it. However, this is not the focus of this post. You got this from here!

Conclusion

We created a simple typescript CLI with bun, @clack/prompts and lowdb. Our CLI asks the user some question and remembers the answers for the next run.

Hopefully this post gave you an overview of how to approach the topic of CLI with typescript.