Use Inquirer.js to Create a Conversational CLI User Interface

Janne Kemppainen |

Your CLI apps don’t have to be boring. Especially when there are plenty of possible configuration options, and the program is not going to be executed from a script, it might be better to provide a conversational user experience.

Inquirer.js is a Node package that can be used to create interactive command line user interfaces. You define the questions, and Inquirer takes care of presenting them to the user in a nice format. Tens of thousands of packages depend on it, so you may have already been using it.

I recently used Inquirer.js on my actions-workflow tool so I wanted to share what I learned during the process.

This blog post is meant to support the official instructions on the project README file. Check the instructions there when you have more specific questions, and remember to use the issues page to find out if someone else has already had the same issue. The repository also contains examples for different use cases.

If you want to make your package easily executable with npx go check out my previous blog post!

Befrore we go into the details here are my key tips for you (I will handle them each more deeply in later sections):

  • The when function is your friend, use it to filter out questions that don’t apply to the user’s previous selections.
  • Use filter to transform the answers, it’s better to have the correct output format immediately than use a separate function afterwards.
  • Remember to validate when a specific format is needed.
  • Organize the answers in a hierarchy so that they are easier to handle in later steps, you can do this by using periods in question names.
  • Don’t hesitate to use multiple sets of questions as the previous results can be used to dynamically build new questions whose amount is not known beforehand.

Getting started

Obviously the first thing you need to do is to install Inquirer.js as a dependency in your project:

$ npm install inquirer

Then you can import inquirer and start using it in the code. In these examples I’m going to use the CommonJS require way for imports, but you can also use the import from "inquirer"; style if you enable ES6 imports.

To make things simple I’m going to assume that the entry point is going to be index.js, and that main points to it in the package.json file:

"main": "index.js",

When you’re at the project root directory you can then call the script with:

$ node .

A basic program could look something like this:

var inquirer = require('inquirer');

inquirer
  .prompt([
  	{
      type: "input",
      name: "username",
      message: "What's your name?"
    }
	])
  .then((answers) => {
  	console.log(`Hello ${answers.username}!`)
  })
  .catch((error) => {
    if (error.isTtyError) {
      console.log("Your console environment is not supported!")
    } else {
      console.log(error)
    }
})

When you run the program it asks for your name and then prints it out.

Inquirer program that asks for your name and greets you

As you can see the basic structure is divided in three parts. First you define the questions as a list of specially formatted objects and give that to the prompt function that presents the questions to the user in order. Next, you resolve the returned promise and process the answers. Possible errors are caught and handled separately.

Questions and answers

Configuring the questions really is the most important thing that you need to know about Inquirer. What you define here will determine how the end result data is going to look. You need to know how to combine different types of questions to create a seamless path for the end user.

The available question types are:

  • list: provides a predetermined set of choices from which the user must choose one
  • rawlist: like a list, but the selected value is the index of one of the entries
  • expand: ask for a single lowercase letter to choose one of the given options, type h to show help for the available options
  • checkbox: like a list but the user can choose many options by toggling item selection with the space key
  • confirm: a confirmation notification that asks for y/N and stores a Boolean value
  • input: asks for arbitrary text input
  • number: accepts a number
  • password: asks for text input but the value is hidden
  • editor: ask for input by opening a temporary file on the system default text editor

When you define a question you should always specify at least the type, name and message fields. Multiple choice questions also need the choices property to define the list of values that the user can choose from. If you provide a default value then the user can quickly continue to the next question, but remember that sometimes it can be useful to not define anything at all.

When organizing your code, it is usually clearer if you put the questions in a separate variable. In some cases you might even put them in another file.

This example could be from an app that collects job applications:

var inquirer = require('inquirer');

const questions = [
    {
        type: "input",
        name: "name",
        message: "What's your first name?"
    },
    {
      	type: "input",
        name: "email",
        message: "What's your email address?"
    },
    {
        type: "list",
        name: "experience",
        message: "How many years of experience you have with JavaScript?",
        choices: [
            "0-1",
            "1-3",
            "3-5",
            "5-10",
            "10+"
        ]
    },
    {
        type: "checkbox",
        name: "technologies",
        message: "What technologies do you know?",
        choices: [
            "React.js",
            "Vue",
            "Angular",
            "Node.js",
            "jQuery",
            "D3.js",
        ]
    },
    {
        type: "number",
        name: "salary",
        message: "What is your salary requirement?"
    }
]

inquirer
  .prompt(questions)
  .then((answers) => {
    console.log(JSON.stringify(answers, null, 2))
  })
  .catch((error) => {
    if (error.isTtyError) {
      console.log("Your console environment is not supported!")
    } else {
      console.log(error)
    }
})

As you can see, I’ve used different question types depending on the desired answer. Name and email need arbitrary text input, while the years of experience has a limited set of categories that we’re interested in. Similarly the tech selection shows a predefined list with checkboxes.

When you run the application and answer the questions the end results will be printed on the console as JSON.

{
  "name": "Janne",
  "email": "[email protected]",
  "experience": "1-3",
  "technologies": [
    "React.js",
    "Vue",
    "Node.js"
  ],
  "salary": 100000
}

As you can see the answers have the same names that we defined in the set of questions. Input and list questions return strings, whereas a checkbox selection produces a list and number creates, well, a number.

Each field in the answers object can be accessed as an object property, for example answers.email.

There are still some issues with our code. In the next section we will improve it a bit.

Validators

When you receive data from a user you should typically validate that it meets your requirements, and ask again when the data doesn’t pass validation.

Add a function called validate to your question object to perform input validation. This can be either synchronous or asynchronous, depending on your needs. The validation function should return true if the value is valid, or an error string when validation fails. This is then shown to the user so that they understand what went wrong.

Validating a name is actually quite difficult, since there are so many languages with many exotic characters. However, we can check that the user wrote at least something. The question object should look something like this:

{
    type: "input",
    name: "name",
    message: "What's your first name?",
	validate(answer) {
        if(!answer) {
            return "Please, fill your name!"
        }
        return true
    }
},

An empty answer evaluates to false, in which case we return an error message. The validation passes when the user types at least something.

Note that the validate function got its name from the shorthand notation. There is also the longer form that you can see here for the email validation (which is also difficult, so I’m using a simpler regex that matches most real world addresses):

{
    type: "input",
    name: "email",
    message: "What's your email address?",
    validate = (answer) => {
        const emailRegex = /^[^\[email protected]][email protected][^\[email protected]]+\.[^\[email protected]]+$/
        if(!emailRegex.test(answer)) {
            return "You have to provide a valid email address!"
        }
        return true
    }
}

Similarly the salary requirement needs validation. If you’ve been testing the code you may have noticed that adding a random character such as the dollar sign causes issues with the number parsing, and the returned value becomes null. Unfortunately, there is currently a bug in Inquirer that prevents the user from giving a valid input if the number parsing fails for the first time. Therefore it might be better to switch to the input type and handle the data as a string instead.

{
    type: "input",
    name: "salary",
    message: "What is your salary requirement?",
    validate(answer) {
      salaryRegex = /^[$]?\d[\d,]*$/
      if(!salaryRegex.test(answer)) {
        return "Not a valid salary!"
      }
      return true
    }
}

The regular expression above accepts strings that may or may not start with the dollar sign, have at least one digit, followed by any number of digits and commas.

Filtering

Since we still want to store the salary result as a number we need to add a filter. The filter function takes the input value and transforms it to the format that you want.

{
    type: "input",
    name: "salary",
    message: "What is your salary requirement?",
    validate(answer) {
      salaryRegex = /^[$]?[\d,]+$/
      if(!salaryRegex.test(answer)) {
        return "Not a valid salary!"
      }
      return true
    },
    filter(answer) {
      const cleaned = answer.replaceAll("$", "").replaceAll(",", "")
      return parseInt(cleaned)
    }
}

This is one possible solution. Since we have already validated the input we know that it can only contain a dollar sign, numbers and commas. If we get rid of the dollar sign and commas we can use parseInt to convert the value to an integer. Therefore we get the same number type as before, but this time it’s being properly validated.

You can use filter to create even more complex transformations. Just remember that you receive the answer as a string, and then you need to return the desired output from your filter function.

Conditional questions

Some questions may be meaningful only when certain preconditions are met. The user could give an answer that might require additional information. In such cases you can configure a question with a when function.

The function receives the current answers as input, and it should return either true or false to tell if the question should be asked or not. The logic is much like the filter function in the previous step, but this time we’re working on the set of already answered questions.

Let’s expand our example once more. In addition to applications, we want to gather anonymous feedback about the form. Because we want to add it as an optional step we add a question to ask if the user is willing to answer the feedback questionnaire. Then we can use the when function to decide if the questions should be shown or not.

The list of questions could contain something like this:

const questions = [
    {
        type: "list",
        name: "survey",
        message: "Would you like to participate in an anonymous survey?",
        choices: ["yes", "no"]
    },
    {
        type: "list",
        name: "happiness",
        message: "How happy were you with the questionnaire?",
        choices: ["Very happy", "Quite happy", "Neutral", "Not quite happy", "Unhappy"],
        when(answers) {
            return answers.survey === "yes"
        }
    },
    {
        type: "input",
        name: "feedback",
        message: "Please give us open feedback (optional)",
        when(answers) {
            return answers.survey === "yes"
        }
    }
]

The when function can fetch the result of the survey step and compare that the input matches the expected value.

Optionally, you can set when directly to a Boolean value. In that case the decision to show or not to show the question must be made beforehand, it cannot react to the answers in the current set of questions.

Dynamic question messages

Much like validation and filtering, we can define the message of the question using a function. This could add a nice personal touch to the user experience. Let’s add a small greeting to the email step:

{
    type: "input",
    name: "email",
    message(answers) {
      return `Hello ${answers.name}! What's your email address?`
    },
    validate: (answer) => {
      const emailRegex = /^[^\[email protected]][email protected][^\[email protected]]+\.[^\[email protected]]+$/
      if (!emailRegex.test(answer)) {
        return "You have to provide a valid email address!"
      }
      return true
    }
  },

When we define messages as a function we can tap into the previous answers, in this case to greet the user by their first name.

Answer hierarchy

You can organize the question results in an object hierarchy. This is especially useful when you have a complex set of questions, or maybe want to construct the desired object structure directly from the question results.

The question names can be delimited with periods . to create a tree structure for the answers. For example, the questionnaire in the previous step should probably be organized in a separate section:

const questions = [
    {
        type: "list",
        name: "survey.participate",
        message: "Would you like to participate in an anonymous survey?",
        choices: ["yes", "no"]
    },
    {
        type: "list",
        name: "survey.happiness",
        message: "How happy were you with the questionnaire?",
        choices: ["Very happy", "Quite happy", "Neutral", "Not quite happy", "Unhappy"],
        when(answers) {
            return answers.survey.participate === "yes"
        }
    },
    {
        type: "input",
        name: "survey.feedback",
        message: "Please give us open feedback (optional)",
        when(answers) {
            return answers.survey.participate === "yes"
        }
    }
]

The result would then look like this:

{
  "survey": {
    "participate": "yes",
    "happiness": "Quite happy",
    "feedback": "The form could've been longer"
  }
}

Note that you cannot store a result that matches a part of the hierarchy, for example in this case the name survey would be invalid.

Asking questions many times

Sometimes you may not know how many times a question should be asked in advance. Remember that you can always create a new set of questions based on the earlier answers.

Here’s a fun little example:

var inquirer = require('inquirer');

const questions = [
    {
        type: "input",
        name: "fruitList",
        message: "List all your favorite fruit",
        filter(answer) {
            return answer.split(/[ ,]+/).filter(Boolean);
        },
        validate(answer) {
            if (answer.length < 1) {
                return "Mention at least one fruit!";
            }
            return true;
        },
    }
]

function getFruitQuestions(answers) {
    const fruitList = answers.fruitList
    const fruitQuestions = []
    for (let i = 0; i < fruitList.length; i++) {
        const fruitName = fruitList[i]
        fruitQuestions.push(
            {
                type: "input",
                name: `fruit.${fruitName}.reason`,
                message: `Why do you like ${fruitName}?`
            }
        )
    }
    return fruitQuestions
}

inquirer
    .prompt(questions)
    .then((answers) => {
        inquirer.prompt(getFruitQuestions(answers)).then((fruitAnswers) => {
            console.log(JSON.stringify(fruitAnswers, null, 2))
        })
    })
    .catch((error) => {
        if (error.isTtyError) {
            console.log("Your console environment is not supported!")
        } else {
            console.log(error)
        }
    })

This app asks the user to list all their favorite fruit. Then it proceeds to ask more detailed questions about them each. Here’s an example invocation:

? List all your favorite fruit: lemon,apple
? Why do you like lemon? They are sour
? Why do you like apple? They are sweet
{
  "fruit": {
    "lemon": {
      "reason": "They are sour"
    },
    "apple": {
      "reason": "They are sweet"
    }
  }
}

In addition to the dynamic amount of questions this example contains other interesting things, too. As you can see, the name and the message are generated based on the current fruit. This makes the user experience feel really natural, and the output is also organized neatly.

Another interesting part can be found from the initial question. It accepts a list of comma or space delimited values. So the user can separate the items with any combination of commas and spaces they desire!

Conclusion

Inquirer is a fun little library that you can use to make your CLI apps more intuitive to use. Hopefully this post helped you understand the key concepts so that you can start supercharging your own apps!

Subscribe to my newsletter

What’s new with PäksTech? Subscribe to receive occasional emails where I will sum up stuff that has happened at the blog and what may be coming next.

powered by TinyLetter | Privacy Policy