Optique: Ein typsicherer CLI-Parser für TypeScript
(optique.dev)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.
- GitHub: https://github.com/dahlia/optique
- Dokumentation: https://optique.dev/
- npm: https://www.npmjs.com/package/@optique/core
- JSR: https://jsr.io/@optique/core
1 Kommentare
Wow, das ist richtig gut! Danke fürs Teilen. Das sollte ich auch mal ausprobieren, wenn ich eine CLI erstelle.