23 คะแนน โดย hongminhee 2025-08-23 | 1 ความคิดเห็น | แชร์ทาง WhatsApp

สวัสดีครับ! เพราะผมสร้างเครื่องมือ CLI ด้วย TypeScript อยู่บ่อย ๆ เลยรู้สึกเสียดายข้อจำกัดของไลบรารีที่มีอยู่ จึงได้ลองสร้างตัวแยกวิเคราะห์ CLI ตัวใหม่ขึ้นมา เลยอยากมาแนะนำให้คนที่สนใจได้รู้จักครับ

ระหว่างพัฒนาแอปพลิเคชัน CLI มีจุดหนึ่งที่รู้สึกไม่สะดวกอยู่เสมอ ไลบรารีตัวแยกวิเคราะห์ CLI ที่มีอยู่ส่วนใหญ่ใช้วัตถุตั้งค่าหรือ API เชิงคำสั่งในการนิยามโครงสร้าง CLI ซึ่งทำให้ไม่เพียงเสียทั้งความปลอดภัยด้านประเภท แต่ยังแสดงโครงสร้าง CLI ที่ซับซ้อนได้ยากด้วย

โดยเฉพาะเวลาจะอธิบายกลุ่มออปชันที่ห้ามใช้ร่วมกัน (mutually exclusive) มักต้องกระจายตรรกะตรวจสอบแยกไว้ตามจุดต่าง ๆ การแสดงข้อจำกัดอย่าง “ออปชันนี้กับออปชันนั้นใช้พร้อมกันไม่ได้” หรือ “ในโหมดนี้อนุญาตเฉพาะออปชันแบบนี้” ให้ออกมาเป็นโค้ดที่สะอาดทำได้ยาก และถึงจะใช้ TypeScript ก็ยังมีหลายกรณีที่ต้องนิยามประเภทของผลลัพธ์จากการแยกวิเคราะห์ด้วยตัวเอง

แนวทางแบบฟังก์ชันที่เรียกว่า parser combinator

เพราะแบบนั้น ผมเลยได้แรงบันดาลใจจาก optparse-applicative ของ Haskell แล้วลองสร้างตัวแยกวิเคราะห์ CLI สำหรับ TypeScript ด้วยแนวทาง functional parser combinator ขึ้นมาครับ

วิธีแบบเดิม:

// วิธีที่พบได้ทั่วไปในไลบรารีเดิม  
const program = new Command()  
  .option('-p, --port <number>', 'port number')  
  .option('-h, --host <string>', 'hostname')  
  .action((options) => {  
    // ประเภทของ options เป็น any หรือไม่ก็ต้องนิยามเอง  
  });  

วิธีของ Optique:

// นำ parser ขนาดเล็กมาประกอบเป็นโครงสร้างใหญ่  
const serverConfig = object({  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  host: option("-h", "--host", string()),  
  verbose: option("-v", "--verbose")  
});  
  
// TypeScript อนุมานประเภทให้อัตโนมัติ!  
// { port: number, host: string, verbose: boolean }  
const config = run(serverConfig);  

จุดเด่น 1: แสดงออปชันที่ห้ามใช้ร่วมกันผ่านโครงสร้างได้

ความแตกต่างที่ใหญ่ที่สุดคือสามารถแสดงกลุ่มออปชันที่ห้ามใช้ร่วมกันได้อย่างเป็นธรรมชาติ ไลบรารีเดิมมักต้องจัดการข้อจำกัดแบบนี้ด้วยตรรกะตรวจสอบเพิ่มเติมแยกต่างหาก แต่ Optique สามารถใช้คอมบิเนเตอร์ or() เพื่อฝังข้อจำกัดไว้ในโครงสร้างเองได้

// โหมดเซิร์ฟเวอร์ vs โหมดไคลเอนต์ - ชุดออปชันต่างกันโดยสิ้นเชิง  
const parser = or(  
  object({  
    mode: constant("server"),  
    port: option("-p", "--port", integer()),  
    workers: option("-w", "--workers", integer()),  
    ssl: option("--ssl")  
  }),  
  object({  
    mode: constant("client"),   
    connect: option("-c", "--connect", string()),  
    timeout: option("-t", "--timeout", integer()),  
    retries: option("--retries", integer())  
  })  
);  
  
// TypeScript สร้าง discriminated union ให้อัตโนมัติ  
// { mode: "server", port: number, workers: number, ssl: boolean } |   
// { mode: "client", connect: string, timeout: number, retries: number }  

ถ้าเป็นไลบรารีแบบเดิม ก็น่าจะต้องตรวจสอบแบบนี้ด้วยตนเอง:

// ความยุ่งยากของวิธีเดิม  
if (options.mode === "server" && options.connect) {  
  throw new Error("--connect ใช้ในโหมดเซิร์ฟเวอร์ไม่ได้");  
}  
if (options.mode === "client" && options.workers) {  
  throw new Error("--workers ใช้ในโหมดไคลเอนต์ไม่ได้");  
}  

จุดเด่น 2: อนุมานประเภทอัตโนมัติแบบสมบูรณ์

const gitLike = or(  
  command("add", object({  
    type: constant("add"),  
    files: multiple(argument(string())),  
    all: option("-A", "--all")  
  })),  
  command("commit", object({  
    type: constant("commit"),  
    message: option("-m", "--message", string()),  
    amend: option("--amend")  
  }))  
);  
  
// ผลลัพธ์ถูกอนุมานเป็น discriminated union โดยอัตโนมัติ  
const result = run(gitLike);  
if (result.type === "add") {  
  // TypeScript ช่วย narrow type ให้เอง  
  console.log(`Adding ${result.files.join(", ")}`);  
}  

จุดเด่น 3: การแยกเป็นโมดูลและการนำกลับมาใช้ซ้ำ

สามารถนำกลุ่มออปชันกลับมาใช้ซ้ำได้ด้วยคอมบิเนเตอร์ merge() ทำให้แชร์ออปชันร่วมระหว่างหลายคำสั่งได้ง่าย

// นิยามกลุ่มออปชันที่ใช้ซ้ำได้  
const networkOptions = object({  
  host: option("--host", string()),  
  port: option("--port", integer())  
});  
  
const authOptions = object({  
  username: option("-u", "--user", string()),  
  password: optional(option("-p", "--password", string()))  
});  
  
// ประกอบใช้ตามต้องการ  
const devMode = merge(networkOptions, object({ debug: option("--debug") }));  
const prodMode = merge(networkOptions, authOptions, loggingOptions);  

จุดเด่น 4: การตรวจสอบในตัวที่หลากหลาย

parser ของค่าต่าง ๆ ไม่ได้มีแค่การแปลงประเภทพื้นฐาน แต่ยังมีการตรวจสอบที่มีความหมายด้วย

const parser = object({  
  // ตรวจสอบว่ามีอยู่จริงในระบบไฟล์  
  inputFile: option("--input", path({ mustExist: true })),  
  
  // ตรวจสอบช่วงหมายเลขพอร์ต  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  
  // จำกัดโปรโตคอลของ URL  
  api: option("--api", url({ allowedProtocols: ["https:"] })),  
  
  // จำกัดตัวเลือกที่อนุญาต  
  logLevel: option("--log", choice(["debug", "info", "warn", "error"]))  
});  

การรองรับรันไทม์

  • @optique/core: รองรับ JavaScript runtime ทุกแบบ (เบราว์เซอร์, edge function ฯลฯ)
  • @optique/run: เวอร์ชันพร้อมใช้สำหรับ Node.js, Bun, Deno

การติดตั้ง:

deno add --jsr @optique/core @optique/run  
npm  add       @optique/core @optique/run  
pnpm add       @optique/core @optique/run  
yarn add       @optique/core @optique/run  
bun  add       @optique/core @optique/run  

ทิ้งท้าย

ถ้าไลบรารี CLI แบบเดิมคือแนวทาง “สร้าง parser จากการตั้งค่า” Optique ก็คือแนวทางเชิงฟังก์ชันแบบ “ประกอบ parser ขนาดเล็กให้เป็น parser ขนาดใหญ่”

โดยเฉพาะเวลาต้องแสดงกลุ่มออปชันที่ห้ามใช้ร่วมกัน ความแตกต่างนี้จะเห็นได้ชัด สามารถแสดงข้อจำกัดของ CLI ที่ซับซ้อนผ่านโครงสร้างของ parser เองได้โดยไม่ต้องมีตรรกะตรวจสอบแยก ทำให้ได้ทั้งความปลอดภัยด้านประเภทและโค้ดที่กระชับไปพร้อมกัน

แน่นอนว่าตอนนี้ยังอยู่ในช่วงพัฒนาเริ่มต้น API อาจมีการเปลี่ยนแปลงได้ แต่ถ้าใครอยากนำความงามของ functional parser combinator มาใช้กับการพัฒนา CLI ด้วย TypeScript ก็น่าลองใช้ดูสักครั้งครับ

1 ความคิดเห็น

 
spilist2 2025-08-25

ว้าว ดีมากเลย! ขอบคุณที่แชร์นะครับ ผมเองก็คงต้องลองใช้ดูตอนทำ CLI เหมือนกัน