สวัสดีครับ! เพราะผมสร้างเครื่องมือ 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 ความคิดเห็น
ว้าว ดีมากเลย! ขอบคุณที่แชร์นะครับ ผมเองก็คงต้องลองใช้ดูตอนทำ CLI เหมือนกัน