23 Punkte von hongminhee 2025-08-23 | 1 Kommentare | Auf WhatsApp teilen

Hallo! Da ich häufig CLI-Tools mit TypeScript entwickle und die Grenzen bestehender Bibliotheken bedauerlich fand, habe ich einen neuen CLI-Parser gebaut. Ich wollte ihn hier gern allen vorstellen, die daran Interesse haben.

Bei der Entwicklung von CLI-Anwendungen gab es immer einen Punkt, den ich unpraktisch fand. Die meisten bestehenden CLI-Parser-Bibliotheken definieren die CLI-Struktur über Konfigurationsobjekte oder imperative APIs. Dadurch leidet nicht nur die Typsicherheit, sondern auch komplexe CLI-Strukturen lassen sich nur schwer ausdrücken.

Insbesondere musste man bei wechselseitig ausschließenden Optionen (mutually exclusive) separate Validierungslogik an vielen Stellen verteilen. Einschränkungen wie „Diese Option und jene Option dürfen nicht gleichzeitig verwendet werden“ oder „In diesem Modus sind nur bestimmte Optionen erlaubt“ ließen sich im Code nur schwer sauber ausdrücken. Und selbst mit TypeScript musste man den Typ des Parsing-Ergebnisses oft manuell definieren.

Ein funktionaler Parser-Combinator-Ansatz

Deshalb habe ich mich von Haskells optparse-applicative inspirieren lassen und einen TypeScript-CLI-Parser im Stil funktionaler Parser-Combinators gebaut.

Bisheriger Ansatz:

// Typischer Ansatz bestehender Bibliotheken  
const program = new Command()  
  .option('-p, --port <number>', 'port number')  
  .option('-h, --host <string>', 'hostname')  
  .action((options) => {  
    // Der Typ von options ist any oder muss manuell definiert werden  
  });  

Ansatz von Optique:

// Kleine Parser werden zu einer größeren Struktur kombiniert  
const serverConfig = object({  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  host: option("-h", "--host", string()),  
  verbose: option("-v", "--verbose")  
});  
  
// TypeScript leitet den Typ automatisch ab!  
// { port: number, host: string, verbose: boolean }  
const config = run(serverConfig);  

Besonderheit 1: Wechselseitig ausschließende Optionen als Struktur ausdrücken

Der größte Unterschied ist, dass sich Gruppen wechselseitig ausschließender Optionen natürlich ausdrücken lassen. Bestehende Bibliotheken müssen solche Einschränkungen meist mit separater Validierungslogik behandeln, während Optique sie mit dem or()-Combinator direkt in die Struktur einbetten kann.

// Server-Modus vs. Client-Modus - vollständig unterschiedliche Optionssets  
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 erzeugt automatisch eine discriminated union  
// { mode: "server", port: number, workers: number, ssl: boolean } |   
// { mode: "client", connect: string, timeout: number, retries: number }  

Bei bestehenden Bibliotheken müsste man solche Validierung wahrscheinlich manuell schreiben:

// Der umständliche bisherige Ansatz  
if (options.mode === "server" && options.connect) {  
  throw new Error("--connect kann im Server-Modus nicht verwendet werden");  
}  
if (options.mode === "client" && options.workers) {  
  throw new Error("--workers kann im Client-Modus nicht verwendet werden");  
}  

Besonderheit 2: Vollständig automatische Typinferenz

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")  
  }))  
);  
  
// Das Ergebnis wird automatisch als discriminated union abgeleitet  
const result = run(gitLike);  
if (result.type === "add") {  
  // TypeScript übernimmt die Type-Narrowing automatisch  
  console.log(`Adding ${result.files.join(", ")}`);  
}  

Besonderheit 3: Modularität und Wiederverwendbarkeit

Mit dem merge()-Combinator lassen sich Optionsgruppen wiederverwenden, sodass gemeinsame Optionen leicht zwischen mehreren Kommandos geteilt werden können.

// Definition wiederverwendbarer Optionsgruppen  
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()))  
});  
  
// Je nach Bedarf kombinieren  
const devMode = merge(networkOptions, object({ debug: option("--debug") }));  
const prodMode = merge(networkOptions, authOptions, loggingOptions);  

Besonderheit 4: Umfangreiche eingebaute Validierung

Die Value-Parser bieten mehr als einfache Typumwandlung und liefern sinnvolle Validierung.

const parser = object({  
  // Prüft, ob die Datei im Dateisystem tatsächlich existiert  
  inputFile: option("--input", path({ mustExist: true })),  
  
  // Validierung des Portnummernbereichs  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  
  // Einschränkung des URL-Protokolls  
  api: option("--api", url({ allowedProtocols: ["https:"] })),  
  
  // Auswahl auf bestimmte Werte beschränken  
  logLevel: option("--log", choice(["debug", "info", "warn", "error"]))  
});  

Runtime-Unterstützung

  • @optique/core: Unterstützung für alle JavaScript-Runtimes (Browser, Edge Functions usw.)
  • @optique/run: Batterien-inbegriffen-Version für Node.js, Bun und Deno

Installation:

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  

Zum Schluss

Während bestehende CLI-Bibliotheken Parser nach dem Muster „einen Parser über Konfiguration erstellen“ aufbauen, verfolgt Optique einen funktionalen Ansatz nach dem Prinzip „kleine Parser zu einem großen Parser kombinieren“.

Gerade beim Ausdrücken wechselseitig ausschließender Optionsgruppen zeigt sich dieser Unterschied besonders deutlich. Komplexe CLI-Einschränkungen lassen sich ohne separate Validierungslogik direkt in der Parser-Struktur ausdrücken, sodass man Typsicherheit und kompakten Code gleichzeitig gewinnt.

Natürlich befindet sich das Projekt noch in einer frühen Entwicklungsphase, daher kann sich die API noch ändern. Wer aber die Eleganz funktionaler Parser-Combinators in die TypeScript-CLI-Entwicklung holen möchte, sollte es einmal ausprobieren.

1 Kommentare

 
spilist2 2025-08-25

Wow, das ist richtig gut! Danke fürs Teilen. Das sollte ich auch mal ausprobieren, wenn ich eine CLI erstelle.