Prompt for y/N Without readline Cruft
Every CLI script needs a confirm() one day. The full Inquirer ceremony is overkill. Here is the small wrapper I copy into deploy scripts when I need 'are you sure? [y/N]' that defaults to no.
By @petrawilson
April 25, 2026
·
Updated May 20, 2026
1,007 views
33
Rate
// Split the prompt into two pieces. The pure half ('did the user type yes?')
// is testable; the IO half (read a line from stdin) is the thin wrapper that
// changes between Node, browser, and a test harness.
// Pure: takes the raw answer string, returns boolean per the default-no rule.
function interpretAnswer(rawAnswer, defaultAnswer) {
const trimmed = String(rawAnswer || '').trim().toLowerCase();
if (trimmed === '') return defaultAnswer;
return trimmed === 'y' || trimmed === 'yes';
}
// The hint is what tells the user the default. Capital N is the convention
// every git/cargo/kubectl prompt uses; veteran terminal users read it as
// 'press Enter for no'.
function promptHint(defaultAnswer) {
return defaultAnswer ? '[Y/n]' : '[y/N]';
}
// Tests of the pure layer; in a real codebase these go in a Jest file.
const cases = [
['', false, false],
['', true, true ],
['y', false, true ],
['Yes', false, true ],
['n', true, false],
['lol', true, false],
[' Y ', false, true ],
];
for (const [input, def, expected] of cases) {
const got = interpretAnswer(input, def);
const marker = got === expected ? 'ok ' : 'FAIL';
console.log(`${marker} interpretAnswer(${JSON.stringify(input)}, default=${def}) = ${got}`);
}
console.log('hint default=false:', promptHint(false));
console.log('hint default=true :', promptHint(true));Splitting the parsing logic from the IO is the design choice that makes this prompt testable. interpretAnswer is a pure function: same input always produces the same output, so the matrix of edge cases (empty string, mixed case, whitespace, junk) is cheap to verify in unit tests. The default-answer parameter is what gives the prompt the right semantics for destructive operations: confirm('Drop database?', { default: false }) defaults to no, so a fat-finger Enter does not nuke the DB. The [y/N] capitalization in promptHint is a convention every veteran terminal user reads as 'default is no'.
// In a Node script you compose the pure parser with readline. The wrapper is
// twelve lines; everything that matters is in the pure layer above.
//
// The code in this accordion is the SHAPE you would copy into a Node script.
// In this snippet we run it against a fake stdin to keep the demo runnable
// in any sandbox.
function interpretAnswer(rawAnswer, defaultAnswer) {
const trimmed = String(rawAnswer || '').trim().toLowerCase();
if (trimmed === '') return defaultAnswer;
return trimmed === 'y' || trimmed === 'yes';
}
// In a real Node script:
// const readline = require('readline');
// function confirm(question, { default: defaultAnswer = false } = {}) {
// const hint = defaultAnswer ? '[Y/n]' : '[y/N]';
// const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
// return new Promise((resolve) => {
// rl.question(`${question} ${hint} `, (answer) => {
// rl.close();
// resolve(interpretAnswer(answer, defaultAnswer));
// });
// });
// }
// const ok = await confirm('Deploy to production?');
//
// For a runnable demo we substitute readline with an injected line-getter:
function makeConfirm(readLine) {
return async function confirm(question, { default: defaultAnswer = false } = {}) {
const hint = defaultAnswer ? '[Y/n]' : '[y/N]';
const answer = await readLine(`${question} ${hint} `);
return interpretAnswer(answer, defaultAnswer);
};
}
// Fake stdin: every readLine() call returns the next item from a queued list.
const inputs = ['y', '', 'NO'];
const readLine = (prompt) => {
const next = inputs.shift();
console.log(prompt + JSON.stringify(next));
return Promise.resolve(next);
};
const confirm = makeConfirm(readLine);
(async () => {
console.log('=> deploy:', await confirm('Deploy to production?'));
console.log('=> migrate:', await confirm('Run migration?', { default: true }));
console.log('=> drop :', await confirm('Drop database?'));
})();The Promise wrapper is the whole trick. readline.question is callback-shaped, but every modern script wants await confirm(...); the resolve in the callback bridges the two without pulling in util.promisify. Injecting the line-getter (rather than calling readline directly) is what lets the same code run under tests, under a fake stdin, or under the real terminal; the production version literally substitutes readline.question for the fake readLine in the snippet. I have shipped this exact split in three deploy scripts; the pure parser caught two off-by-one bugs in interactive code that would have been unfindable with stdin in the loop.
