# Day 36：重構 CLI 驗證流程：從過度抽象到剛好夠用

By [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake) · 2026-01-12

---

主題：在 Node.js 環境下，如何為 CLI 程式設計「可讀、好維護」的輸入驗證流程。

* * *

一級函式與驗證器設計
----------

在 JavaScript 中，函式是一級公民，可以：

*   存在變數、陣列、物件中
    
*   當作參數傳入其他函式
    
*   當作回傳值傳出
    
*   擁有屬性與方法\[1\]
    

這非常適合拿來實作「驗證器」：

    // 單一職責的驗證器
    export function validatePositiveInt(raw, min = 1, max = Infinity) {
      const trimmed = String(raw).trim();
    
      if (!/^[+-]?[0-9]+$/.test(trimmed)) {
        throw new Error("請輸入整數");
      }
    
      const num = parseInt(trimmed, 10);
    
      if (num < min || num > max) {
        if (max === Infinity) {
          throw new Error(`請輸入大於等於 ${min} 的正整數`);
        }
        throw new Error(`請輸入 ${min} 到 ${max} 之間的正整數`);
      }
    
      return num;
    }
    

設計重點：

*   驗證器是「普通函式」，接 `string`、丟錯誤或回傳轉型後的值。
    
*   名稱直接表意（`validatePositiveInt`），不需要額外文件。\[1\]
    

* * *

反例：過度抽象的 composeValidators
--------------------------

曾經的設計：為了「高可重用性」，先做一個驗證器組合器 `composeValidators`，然後所有驗證流程都要經過這個組合器。

    // 抽象過度的範例（不推薦）
    import { composeValidators } from "./composeValidators.js";
    import {
      validateIntegerString,
      toInt,
      validatePositiveInt,
    } from "./validators.js";
    
    export const config = {
      validator: composeValidators([
        validateIntegerString, // 格式檢查
        toInt,                 // 型別轉換
        validatePositiveInt,   // 範圍檢查
      ]),
    };
    

問題：

*   需要先理解 `composeValidators` 的執行順序與錯誤傳遞規則，認知負擔高。
    
*   每層可能拋出不同錯誤訊息，很難維持一致性。
    
*   回傳值若被包成 `{ value }` 之類的物件，只是多一層樣板。  
    （「為了統一而統一」，但沒有實質收益。）
    

對單純的 CLI 練習題或小工具來說，這種抽象層級明顯超過實際需求。

* * *

改用「單一函式驗證器」的結構
--------------

重構後的目標：

*   一個驗證需求，一個函式。
    
*   直接回傳值，不包殼。
    
*   所有錯誤訊息在函式內統一管理。
    

    // util/validators.js
    function validateFormat(input, pattern, errorMsg) {
      const trimmed = String(input).trim();
      if (!pattern.test(trimmed)) {
        throw new Error(errorMsg);
      }
      return trimmed;
    }
    
    function validateRange(num, min, max, errorMsg) {
      if (num < min || (max !== undefined && num > max)) {
        throw new Error(errorMsg);
      }
      return num;
    }
    
    export function validateNonNegativeInt(raw) {
      const trimmed = validateFormat(raw, /^[+-]?[0-9]+$/, "請輸入整數");
      const num = parseInt(trimmed, 10);
      return validateRange(num, 0, undefined, "請輸入非負整數");
    }
    
    export function validateEvenInt(raw, min = 2) {
      const trimmed = validateFormat(raw, /^[+-]?[0-9]+$/, "請輸入整數");
      const num = parseInt(trimmed, 10);
    
      if (num < 0) {
        throw new Error("請輸入非負整數");
      }
    
      if (num % 2 !== 0) {
        throw new Error("請輸入偶數");
      }
    
      return validateRange(num, min, undefined, `請輸入大於等於 ${min} 的偶數`);
    }
    

使用方式：

    import { validateNonNegativeInt } from "./util/validators.js";
    
    const config = {
      name: "visitNum",
      prompt: "請輸入人數：",
      validator: validateNonNegativeInt,
    };
    

優點：

*   不需要閱讀 `composeValidators` 就能理解驗證邏輯。
    
*   錯誤訊息路徑清楚：「格式」→「範圍」→「業務規則」。
    
*   型別由 JS 自己承擔（直接回傳 `number`），不多包一層物件。\[1\]
    

* * *

CLI 輸入：為什麼改用遞迴而不用 while
-----------------------

情境：寫一個 CLI 工具，重複詢問使用者輸入，直到通過驗證。

### 初版：`while(true)` + `return` 退出

    // 反例：while(true) + return
    import { createInterface } from "node:readline/promises";
    import { stdin as input, stdout as output } from "node:process";
    
    function createRl() {
      return createInterface({ input, output });
    }
    
    export async function askWithValidator(prompt, validator) {
      let rl = createRl();
    
      while (true) {
        const raw = await rl.question(prompt);
    
        try {
          const value = validator(raw);
          rl.close();
          return value; // 同時結束迴圈與函式
        } catch (error) {
          console.log("輸入錯誤，請重新輸入");
          rl.close();
          rl = createRl();
        }
      }
    }
    

問題：

*   `return` 同時負責「跳出迴圈」與「結束函式」，職責混在一起。
    
*   除錯時不容易判斷是迴圈控制有誤，還是函式邏輯有誤。
    
*   `while(true)` 需要讀者在腦中模擬可能的結束條件，本身認知負擔就高。\[2\]\[3\]
    

即使改成 `break` 也一樣會有「必須在腦中跑迴圈」的問題，只是職責稍微清楚一些：

    // 仍然偏難讀：需要外部變數 + 模擬迴圈狀態
    export async function askWithValidator(prompt, validator) {
      let rl = createRl();
      let result;
    
      while (true) {
        const raw = await rl.question(prompt);
    
        try {
          result = validator(raw);
          break;
        } catch (error) {
          console.log("輸入錯誤，請重新輸入");
          rl.close();
          rl = createRl();
        }
      }
    
      rl.close();
      return result;
    }
    

### 重構：單一遞迴函式，去掉 while

更直覺的方式是改成「錯了就再問一次」的遞迴寫法：

    // 版本 1：每次呼叫都建立/關閉一次 rl
    import readline from "node:readline/promises";
    import { stdin as input, stdout as output } from "node:process";
    
    export function createRl() {
      return readline.createInterface({ input, output });
    }
    
    export async function askWithValidatorUntilPass(prompt, validator) {
      const rl = createRl();
      const raw = await rl.question(prompt);
    
      try {
        const value = validator(raw);
        rl.close();
        return value;
      } catch (error) {
        console.log(error.message);
        rl.close();
        return askWithValidatorUntilPass(prompt, validator);
      }
    }
    

閱讀時的心智模型變成：

1.  問一次。
    
2.  驗證成功 → 回傳。
    
3.  驗證失敗 → 顯示錯誤 → 再呼叫自己一次。
    

幾乎不需要在腦中模擬狀態。對「重試直到成功」這種流程，遞迴比 while 更自然。\[3\]\[2\]

### 進一步優化：外層管理 readline 生命週期

為了避免每次遞迴都重新建立 `readline.Interface`，可以把資源管理抽到外層，內層只管重試：

    import readline from "node:readline/promises";
    import { stdin as input, stdout as output } from "node:process";
    
    export function createRl() {
      return readline.createInterface({ input, output });
    }
    
    export async function askWithValidatorUntilPass(prompt, validator) {
      const rl = createRl();
    
      async function attempt() {
        const raw = await rl.question(prompt);
    
        try {
          return validator(raw);
        } catch (error) {
          console.log(error.message || "輸入錯誤，請重新輸入");
          return attempt();
        }
      }
    
      try {
        return await attempt();
      } finally {
        rl.close();
      }
    }
    

結構上的分工：

*   外層：建立與關閉 `rl`（資源管理）。
    
*   內層 `attempt()`：只負責「問 → 驗證 → 成功或重試」。
    

這樣既避免了 while，也讓職責非常清晰。Node 官方文件也建議為 `readline/promises` 建立 interface 後，以 async/await 形式操作問題流程。\[4\]\[5\]

* * *

統一 async/await 與錯誤處理風格
----------------------

在 CLI 主程式中，常見兩種寫法：

    // 寫法 A：async 函式 + 外層 .catch()
    async function main() {
      // ...
    }
    
    main().catch((err) => {
      console.error("程式發生未預期錯誤：", err);
      process.exit(1);
    });
    

    // 寫法 B：async 函式 + 內部 try/catch
    async function main() {
      try {
        // ...
      } catch (err) {
        console.error("程式發生未預期錯誤：", err);
        process.exit(1);
      }
    }
    
    main();
    

為了風格一致與可讀性，多數情況下選擇「函式內使用 async/await，就同時用 try/catch 在函式內處理錯誤」會比較好；這與 MDN 對 `async function` + `try...catch` 的示範風格相符。\[6\]\[7\]

範例：

    import { askWithValidatorUntilPass } from "./util/askWithValidatorUntilPass.js";
    import { validatePositiveInt } from "./util/validators.js";
    
    async function main() {
      console.log("=== 細菌分裂總數量計算機 ===\n");
    
      try {
        const hours = await askWithValidatorUntilPass(
          "請輸入經過的小時數：",
          (raw) => validatePositiveInt(raw, 1, 48),
        );
    
        const result = 2 ** hours;
        console.log("\n結果：", result);
      } catch (err) {
        console.error("程式發生未預期錯誤：", err);
        process.exit(1);
      }
    }
    
    main();
    

* * *

設計原則整理
------

1.  **函式命名即文件**  
    `validatePositiveInt`、`validateEvenInt` 比 `validate`、`check` 清楚許多。\[1\]
    
2.  **錯誤訊息分層**
    
    *   格式錯誤：「請輸入整數」
        
    *   範圍錯誤：「請輸入非負整數」或「請輸入 1–10 之間的整數」
        
    *   業務規則錯誤：「請輸入偶數」
        
3.  **直接回傳，不過度包裝**  
    讓錯誤靠 `throw`，成功靠回傳值，不需要額外 `{ value, errors }` 外殼。
    
4.  **避免不必要的抽象**  
    沒有實際需求時，不要先設計通用的 validator pipe／chain；一個清楚的函式往往已足夠。
    
5.  **降低認知負擔優先**  
    在迭代流程上，優先順序大致可以是：
    
        認知負擔：遞迴 < 條件明確的迴圈 < while(true) + break < while(true) + return
        
    
6.  **風格一致性**
    
    *   若用 async/await，就搭配 try/catch。
        
    *   一致的錯誤處理方式讓程式碼更好維護。\[7\]\[6\]
        

[1](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide)[2](https://dev.to/thawkin3/recursion-vs-loops-in-javascript-14em)[3](https://community.appsmith.com/content/blog/recursion-vs-loops-simple-introduction-elegant-javascript)[4](https://www.w3schools.com/nodejs/nodejs_readline.asp)[5](https://nodejs.cn/api-v18/readline.html)[6](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/async_function)[7](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Statements/try...catch)

---

*Originally published on [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/day-36)*
