Skip to content
aviral gupta

// B5.2 · ~32 min · Beginner

Reading input with readline

After this lesson you can ask questions with readline/promises, read every line of input until it ends, validate answers in a loop, and pipe or redirect input into a program.

Lesson 2 of 5 in B5 Events, input and first tests

You will be able to

  • Ask a question with rl.question and close the interface so the program can end
  • Read every line of input with for await, and know when and how the input ends
  • Validate answers in a loop, and give a program its input with a pipe or a file
  1. Warm-up · Activity 1 of 7

    Warm-up from B5.1: a readline interface is an EventEmitter. The input is the two lines a and b, piped in. What does the program print?

    import {createInterface} from 'node:readline/promises';
    
    const rl = createInterface({input: process.stdin});
    rl.on('line', (line) => console.log('line', line));
    rl.on('close', () => console.log('closed'));
    console.log('ready');
  2. Predict · Activity 2 of 7

    Predict before you read on. You run this program in a terminal, type Ada and press Enter. What happens?

    import {createInterface} from 'node:readline/promises';
    
    const rl = createInterface({input: process.stdin, output: process.stdout});
    const name = await rl.question('Name? ');
    console.log('Hi ' + name + '!');
  3. Practice · Activity 3 of 7

    rl comes from node:readline/promises. Fill in the keyword, so that name is the typed line and not a promise.

    const name = ____ rl.question('Name? ');
    const name = rl.question('Name? ');
  4. Practice · Activity 4 of 7

    Match each piece of readline code to what it does.

  5. Practice · Activity 5 of 7

    The input is x, an empty line, y, and last, with no newline after last: printf 'x\n\ny\nlast' | node main.js. What does the program print?

    import {createInterface} from 'node:readline/promises';
    
    const rl = createInterface({input: process.stdin});
    let count = 0;
    for await (const line of rl) count++;
    console.log(count + ' lines');
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Typed by a person, this program works. Now the two answers are piped in: printf '3\n4\n' | node main.js. What happens?

    import {createInterface} from 'node:readline/promises';
    
    const rl = createInterface({input: process.stdin, output: process.stdout});
    const first = await rl.question('First? ');
    console.log('got ' + first);
    const second = await rl.question('Second? ');
    console.log('got ' + second);
    rl.close();
  7. Apply · Activity 7 of 7

    Mini-task. Write main.js that asks 'Temperature in °C? ' with rl.question, closes the interface, and prints the temperature in °F (°C × 9 / 5 + 32). For an answer that is not a number, write a message to stderr and set exit code 1 (B1.4). Run it three ways: type the answer, then echo 21 | node main.js, then node main.js < temp.txt with a file temp.txt that holds 21.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

Asking until the answer is valid

The program asks how many tickets to buy and keeps asking until the answer is a whole number from 1 to 10. parseTickets does the checking and knows nothing about readline. The loop reads one line per prompt; a valid answer ends it with break. Here the answers abc, 0 and 4 are piped in. Because the input is not a terminal, the answers are not echoed, so each answer's reply follows its prompt on the same line.

main.js

import {createInterface} from 'node:readline/promises';

// Returns the number of tickets, or null when the answer is not 1 to 10.
function parseTickets(answer) {
  const text = answer.trim();
  const n = Number(text);
  return text !== '' && Number.isInteger(n) && n >= 1 && n <= 10 ? n : null;
}

const rl = createInterface({input: process.stdin, output: process.stdout});
rl.setPrompt('Tickets (1-10)? ');
rl.prompt();

let tickets = null;
for await (const line of rl) {
  tickets = parseTickets(line);
  if (tickets !== null) break; // leaving the loop closes rl
  console.log('"' + line + '" is not a whole number from 1 to 10');
  rl.prompt(); // ask again
}

if (tickets === null) console.log('The input ended without a valid answer.');
else console.log(tickets + ' tickets cost ' + tickets * 12 + ' euros');

input.txt

abc
0
4

Run it with

node main.js

Output

Tickets (1-10)? "abc" is not a whole number from 1 to 10
Tickets (1-10)? "0" is not a whole number from 1 to 10
Tickets (1-10)? 4 tickets cost 48 euros
  • rl.prompt() wrote Tickets (1-10)? before each answer; it adds no newline.
  • Each invalid answer got a message and a new prompt; the loop went on.
  • The valid answer 4 ended the loop with break, which also closed the interface, so the program ended.
  • Had the input ended without a valid answer, the loop would have ended too, with tickets still null.

Exercises

Exercise 1 of 2

Check an answer

Write the checking part of a question loop; it runs in the browser too. parseNumber(answer, min, max) trims the answer. Empty: {error: 'Please type a number'}. Not a whole number (an optional minus and digits only, so not 2.5 or 1e1): {error: '"2.5" is not a whole number'}, with the trimmed answer in quotes. Below min: {error: 'The smallest is 1'}; above max: {error: 'The largest is 10'}, with the real limits. Otherwise {value: n}, a number.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the JavaScript runner (up to 0.1 MB) and keeps it cached. Your code runs in your browser’s own engine and stays on your device.

Hints
  1. Hint 1

    Start with const text = answer.trim(); and return {error: 'Please type a number'} when text is ''.

  2. Hint 2

    Test the form with a regular expression: /^-?[0-9]+$/.test(text). Number alone would accept 2.5, 1e1 and 0x10.

  3. Hint 3

    Then const n = Number(text); compare it with min and max, and return {value: n} last.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// Checks one typed answer: a whole number from min to max.
// Returns {value: n} or {error: text}.
export function parseNumber(answer, min, max) {
  const text = answer.trim();
  if (text === '') return {error: 'Please type a number'};
  if (!/^-?[0-9]+$/.test(text)) return {error: '"' + text + '" is not a whole number'};
  const n = Number(text);
  if (n < min) return {error: 'The smallest is ' + min};
  if (n > max) return {error: 'The largest is ' + max};
  return {value: n};
}

console.log(parseNumber(' 4 ', 1, 10));
console.log(parseNumber('2.5', 1, 10));
console.log(parseNumber('11', 1, 10));
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

// Checks one typed answer: a whole number from min to max.
// Returns {value: n} or {error: text}.
export function parseNumber(answer, min, max) {
  return {value: Number(answer)};
}

console.log(parseNumber(' 4 ', 1, 10));
console.log(parseNumber('2.5', 1, 10));
console.log(parseNumber('11', 1, 10));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {parseNumber} from './main.js';

test('a whole number in range gives its value, spaces trimmed', () => {
  assert.deepEqual(parseNumber(' 4 ', 1, 10), {value: 4}, `parseNumber(' 4 ', 1, 10) gave ${JSON.stringify(parseNumber(' 4 ', 1, 10))}`);
  assert.deepEqual(parseNumber('-3', -5, 5), {value: -3}, `parseNumber('-3', -5, 5) gave ${JSON.stringify(parseNumber('-3', -5, 5))}`);
});

test('an empty answer asks for a number', () => {
  assert.deepEqual(parseNumber('   ', 1, 10), {error: 'Please type a number'}, `parseNumber('   ', 1, 10) gave ${JSON.stringify(parseNumber('   ', 1, 10))}`);
});

test('text, decimals and exponents are not whole numbers', () => {
  for (const answer of ['abc', '2.5', '1e1', '4 tickets']) {
    const got = parseNumber(answer, 1, 10);
    assert.deepEqual(got, {error: `"${answer}" is not a whole number`}, `parseNumber('${answer}', 1, 10) gave ${JSON.stringify(got)}`);
  }
});

test('numbers outside the range name the limit', () => {
  assert.deepEqual(parseNumber('0', 1, 10), {error: 'The smallest is 1'}, `parseNumber('0', 1, 10) gave ${JSON.stringify(parseNumber('0', 1, 10))}`);
  assert.deepEqual(parseNumber('11', 1, 10), {error: 'The largest is 10'}, `parseNumber('11', 1, 10) gave ${JSON.stringify(parseNumber('11', 1, 10))}`);
  assert.deepEqual(parseNumber('120', 0, 99), {error: 'The largest is 99'}, `parseNumber('120', 0, 99) gave ${JSON.stringify(parseNumber('120', 0, 99))}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Exercise 2 of 2

A shopping list from standard input

Read a shopping list from standard input, one item per line, with for await. Trim each line and skip empty ones. A line end stops the reading; lines after it are ignored. Then print the items numbered, 1. milk, 2. bread, and last N items (0 items for none). Try printf 'milk\n\n bread \nend\ntea\n' | node main.js, then node --test.

This exercise needs Node.js on your computer (the browser version cannot run it). The files and commands are below.

Hints
  1. Hint 1

    In the loop, const item = line.trim(); then if (item === "end") break; and push only items that are not empty.

  2. Hint 2

    After the loop, items.forEach((item, i) => console.log(i + 1 + '. ' + item)); numbers the list from 1.

  3. Hint 3

    Print items.length + ' items' last; with no items, the forEach prints nothing and you get 0 items.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: process.stdin});
const items = [];
for await (const line of rl) {
  const item = line.trim();
  if (item === 'end') break;
  if (item !== '') items.push(item);
}

items.forEach((item, i) => console.log(i + 1 + '. ' + item));
console.log(items.length + ' items');
Run it on your computer

Install Node.js 24 LTS or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.js

import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: process.stdin});
const items = [];
for await (const line of rl) {
  items.push(line);
}

items.forEach((item) => console.log(item));

main.test.js

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';

test('items are trimmed and numbered, empty lines skipped', async () => {
  const printed = await runMain({stdin: 'milk\n\n bread \n'});
  assert.equal(printed, '1. milk\n2. bread\n2 items\n', `for milk, an empty line and " bread " the program printed:\n${printed}`);
});

test('a line end stops the reading', async () => {
  const printed = await runMain({stdin: 'milk\nend\ntea\n'});
  assert.equal(printed, '1. milk\n1 items\n', `for milk, end, tea the program printed:\n${printed}`);
});

test('the last line counts without a newline', async () => {
  const printed = await runMain({stdin: 'eggs\nflour'});
  assert.equal(printed, '1. eggs\n2. flour\n2 items\n', `for eggs and flour without a final newline the program printed:\n${printed}`);
});

test('no items prints 0 items', async () => {
  const printed = await runMain({stdin: ''});
  assert.equal(printed, '0 items\n', `for empty input the program printed:\n${printed}`);
});

package.json

{
  "type": "module"
}

package.json tells Node.js that the .js files are modules; keep it in the folder.

Run the program:

node main.js

Run the checks (needs learnrun.js in the same folder):

node --test
Download learnrun.js

Common mistakes

Asking a question after the for await loop

import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: process.stdin, output: process.stdout});
let total = 0;
for await (const line of rl) total += Number(line);
const again = await rl.question('Add more? ');
console.log(total, again);

What Node.js prints

Error [ERR_USE_AFTER_CLOSE]: readline was closed

Why, and the fix

A for await loop over rl ends only when the input has ended, and by then the interface is closed. A question asked afterwards rejects, so the program crashes. Ask your questions before the loop, or treat the extra answer as one more line inside the loop, for example a line done that ends it with break.

Using stdin without importing it

import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: stdin, output: stdout});
const name = await rl.question('Name? ');
console.log('Hi ' + name + '!');
rl.close();

What Node.js prints

ReferenceError: stdin is not defined

Why, and the fix

The readline docs write {input: stdin, output: stdout} after import {stdin, stdout} from 'node:process'. Copy only the createInterface line and the names do not exist. Either add that import, or write process.stdin and process.stdout: process is a global, stdin is not.

Asking two questions of piped input

// $ printf 'Ada\nBerlin\n' | node main.js
import {createInterface} from 'node:readline/promises';

const rl = createInterface({input: process.stdin, output: process.stdout});
const name = await rl.question('Name? ');
const city = await rl.question('City? ');
console.log(name + ' lives in ' + city);
rl.close();

What Node.js prints

Warning: Detected unsettled top-level await

Why, and the fix

Piped input (and input from a file) arrives at once. The first question takes its line; the next line is read before the second question is asked, and is lost. The second question waits for a line that never comes, the input has ended, and Node.js stops with exit code 13. With no input at all, the first question already does so. For several answers, read the lines with one for await loop.

JavaScript in the browser: your browser’s own engine, in a sandboxed worker. Syntax errors are located with acorn 8.18.0, MIT. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Ask, await, close

Import createInterface from 'node:readline/promises' and create rl = createInterface({input: process.stdin, output: process.stdout}). await rl.question('Name? ') writes the question to the output and resolves to the next line typed, without its newline. The answer is always a string: convert it with Number yourself. While the interface is open it waits for more input, so in a terminal the program does not end, even after its last line has run. rl.close() releases the input, and then the program can end. A question asked after rl.close() rejects with ERR_USE_AFTER_CLOSE. node:readline, without /promises, has the older callback version of question.

Every line, until the input ends

for await (const line of rl) gives you each line in turn, empty lines included, and the last one even without a newline at its end. The loop ends when the input ends: when you press Ctrl+D in a terminal, or when a pipe or a file has no more data. Then the interface emits its close event; rl is an EventEmitter, as in B5.1, with line and close events. break leaves the loop early and closes the interface. Start the loop right after createInterface: lines that arrive while the program awaits something else, such as a question, are lost.

Validate in a loop, pipe input in

People type anything. Keep the checking in a small function that returns a value or null, and loop: rl.setPrompt('Age? ') and rl.prompt() write the prompt, the for await loop gets the answer, a valid one ends the loop with break, an invalid one gets a message and another rl.prompt(). Input need not be typed: echo 3 | node main.js or node main.js < answers.txt connects stdin to a pipe or a file; nothing arrives in process.argv. Piped input arrives all at once, so a second rl.question finds its line already gone; Node.js then stops with Warning: Detected unsettled top-level await (exit code 13). Several answers: read them with one for await loop.

Sources

Last reviewed October 4, 2026