node:readline and node:readline/promises match Node’s readline: line
iteration over any readable stream, prompts and questions, and the cursor helpers. node:tty is covered
below.
Read lines
An interface is async-iterable, one line per step, with the line ending removed. This counts lines and words on stdin:
// lines.ts
import { createInterface } from "node:readline";
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
let count = 0;
let words = 0;
for await (const line of rl) {
count++;
words += line.split(/\s+/).filter(Boolean).length;
}
console.log(`${count} lines, ${words} words`);
printf 'the quick brown fox\njumps over\n\nthe lazy dog\n' | elide lines.ts
4 lines, 9 words
Any readable stream works as input; for a file, pass fs.createReadStream(path). crlfDelay: Infinity treats
\r\n as one line break.
Ask a question
question() from node:readline/promises writes the query and resolves with the next line:
// confirm.ts
import { createInterface } from "node:readline/promises";
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question("Delete 3 files? [y/N] ");
rl.close();
console.log(answer.trim().toLowerCase() === "y" ? "Deleted." : "Kept.");
echo y | elide confirm.ts
Delete 3 files? [y/N] Deleted.
Pass { signal } to give up on an answer; the promise rejects with an AbortError:
const answer = await rl.question("Name? ", { signal: AbortSignal.timeout(10_000) });
The callback form, require("node:readline").createInterface(…).question(query, callback), works the same way.
Note
With piped input, readline reads ahead: lines that arrive before the next question() are emitted as line
events, not answers. To script several answers, read them with for await or a line listener.
Prompt loop
prompt() writes the prompt, and each line event delivers one entry. close() ends the loop and emits close:
// calc.ts
import { createInterface } from "node:readline";
const ops: Record<string, (x: number, y: number) => number> = {
"+": (x, y) => x + y,
"-": (x, y) => x - y,
"*": (x, y) => x * y,
"/": (x, y) => x / y,
};
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "calc> " });
rl.prompt();
rl.on("line", (line) => {
const expr = line.trim();
if (expr === "exit") return rl.close();
const [a, op, b] = expr.split(" ");
const apply = ops[op];
console.log(apply ? apply(Number(a), Number(b)) : "usage: NUMBER (+|-|*|/) NUMBER");
rl.prompt();
});
rl.on("close", () => console.log("bye"));
printf '2 + 3\n6 * 7\nexit\n' | elide calc.ts
calc> 5
calc> 42
calc> bye
Redraw a line
cursorTo, moveCursor, clearLine and clearScreenDown write the matching escape sequences to a stream. Draw
only when the output is a terminal, so logs and pipes get clean text:
// progress.ts
import { clearLine, cursorTo } from "node:readline";
import { setTimeout as sleep } from "node:timers/promises";
const total = 5;
for (let done = 0; done <= total; done++) {
if (process.stdout.isTTY) {
cursorTo(process.stdout, 0);
clearLine(process.stdout, 1);
process.stdout.write(`[${"#".repeat(done).padEnd(total, ".")}] ${done}/${total}`);
}
await sleep(100);
}
if (process.stdout.isTTY) process.stdout.write("\n");
console.log("Done.");
In a terminal the bar fills in place; elide progress.ts | cat prints only Done..
Read single keys
emitKeypressEvents turns raw input into keypress events with the key’s name and modifiers. Put the terminal in
raw mode to receive each key as it is pressed, and restore it before exiting:
// keys.ts
import { emitKeypressEvents } from "node:readline";
if (!process.stdin.isTTY) {
console.error("keys.ts needs a terminal");
process.exit(1);
}
emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
console.log("Press keys; q or Ctrl+C quits.");
process.stdin.on("keypress", (text, key) => {
if (key.name === "q" || (key.ctrl && key.name === "c")) {
process.stdin.setRawMode(false);
process.exit(0);
}
console.log(`${key.ctrl ? "Ctrl+" : ""}${key.name ?? text}`);
});
Terminals with node:tty
node:tty matches Node’s tty: isatty(fd), ReadStream and
WriteStream. process.stdin.isTTY, process.stdout.isTTY, process.stdout.columns and process.stdout.rows
report the terminal:
// tty.ts
import { isatty } from "node:tty";
console.log("stdin is a terminal:", isatty(0));
console.log("stdout is a terminal:", isatty(1));
if (process.stdout.isTTY) console.log("size:", process.stdout.columns, "x", process.stdout.rows);
In a 100-by-30 terminal, elide tty.ts prints:
stdin is a terminal: true
stdout is a terminal: true
size: 100 x 30
Differences from Node
readline/promisesdoes not export theReadlineclass; use the cursor helpers fromnode:readline.import { stdin, stdout } from "node:process"fails; use theprocessglobal, as in the examples above.
See also
- Build a command-line tool: arguments, input, prompts, color and exit codes together.
processforargv,env,exitCodeand the standard streams.
Reference
Status: Partial · Not measured · since 1.1.0
Exports
| Export | Kind | Status | Notes |
|---|---|---|---|
ReadlineInterface | class | Full | close(), pause(), resume(), question(), prompt(), write(), line, cursor |
createInterface() | function | Full | Creates a new readline.Interface instance |
clearLine() | function | Full | Clears the current line of the given TTY stream in a specified direction |
clearScreenDown() | function | Full | Clears the screen from the current position of the cursor down |
cursorTo() | function | Full | Moves the cursor to the specified position in the given TTY stream |
moveCursor() | function | Full | Moves the cursor relative to its current position in the given TTY stream |
tty
| Export | Kind | Status | Notes |
|---|---|---|---|
isatty() | function | Full | Returns true if the given fd is associated with a TTY |
ReadStream | property | Full | The ReadStream class for reading from a TTY |
WriteStream | property | Full | The WriteStream class for writing to a TTY |
Not implemented
readline/promises.Readline.