Hören Sie auf, if-Anweisungen für Ihre CLI-Flags zu schreiben
洪 民憙 (Hong Minhee) @hongminhee@hackers.pub
Wenn Sie CLI-Tools entwickelt haben, haben Sie wahrscheinlich Code wie diesen geschrieben:
if (opts.reporter === "junit" && !opts.outputFile) {
throw new Error("--output-file is required for junit reporter");
}
if (opts.reporter === "html" && !opts.outputFile) {
throw new Error("--output-file is required for html reporter");
}
if (opts.reporter === "console" && opts.outputFile) {
console.warn("--output-file is ignored for console reporter");
}
Vor einigen Monaten habe ich Hören Sie auf, CLI-Validierung zu schreiben. Parsen Sie es gleich beim ersten Mal richtig. über das korrekte Parsen einzelner Optionswerte geschrieben. Aber es behandelte nicht die Beziehungen zwischen Optionen.
Im obigen Code macht --output-file nur Sinn, wenn --reporter auf junit oder html gesetzt ist. Wenn es console ist, sollte die Option überhaupt nicht existieren.
Wir verwenden TypeScript. Wir haben ein leistungsstarkes Typsystem. Und trotzdem schreiben wir hier Laufzeitprüfungen, bei denen der Compiler nicht helfen kann. Jedes Mal, wenn wir einen neuen Reporter-Typ hinzufügen, müssen wir daran denken, diese Prüfungen zu aktualisieren. Bei jedem Refactoring hoffen wir, dass wir keine vergessen haben.
Der Stand der TypeScript CLI-Parser
Die alte Garde – Commander, yargs, minimist – wurde entwickelt, bevor TypeScript zum Mainstream wurde. Sie geben Ihnen Sammlungen von Strings und überlassen die Typsicherheit dem Anwender.
Aber wir haben Fortschritte gemacht. Moderne TypeScript-First-Bibliotheken wie cmd-ts und Clipanion (die Bibliothek, die Yarn Berry antreibt) nehmen Typen ernst:
// cmd-ts
const app = command({
args: {
reporter: option({ type: string, long: 'reporter' }),
outputFile: option({ type: string, long: 'output-file' }),
},
handler: (args) => {
// args.reporter: string
// args.outputFile: string
},
});
// Clipanion
class TestCommand extends Command {
reporter = Option.String('--reporter');
outputFile = Option.String('--output-file');
}
Diese Bibliotheken leiten Typen für einzelne Optionen ab. --port ist eine number. --verbose ist ein boolean. Das ist ein echter Fortschritt.
Aber hier ist, was sie nicht können: ausdrücken, dass --output-file erforderlich ist, wenn --reporter junit ist, und verboten, wenn --reporter console ist. Die Beziehung zwischen Optionen wird nicht im Typsystem erfasst.
Also schreiben Sie trotzdem Validierungscode:
handler: (args) => {
// Sowohl cmd-ts als auch Clipanion benötigen dies
if (args.reporter === "junit" && !args.outputFile) {
throw new Error("--output-file required for junit");
}
// args.outputFile ist immer noch string | undefined
// TypeScript weiß nicht, dass es definitiv string ist, wenn reporter "junit" ist
}
Rusts clap und Pythons Click haben requires und conflicts_with Attribute, aber auch diese sind Laufzeitprüfungen. Sie ändern den Ergebnistyp nicht.
Wenn die Parser-Konfiguration über Optionsbeziehungen Bescheid weiß, warum taucht dieses Wissen nicht im Ergebnistyp auf?
Beziehungen mit conditional() modellieren
Optique behandelt Optionsbeziehungen als Konzept erster Klasse. Hier ist das Test-Reporter-Szenario:
import { conditional, object } from "@optique/core/constructs";
import { option } from "@optique/core/primitives";
import { choice, string } from "@optique/core/valueparser";
import { run } from "@optique/run";
const parser = conditional(
option("--reporter", choice(["console", "junit", "html"])),
{
console: object({}),
junit: object({
outputFile: option("--output-file", string()),
}),
html: object({
outputFile: option("--output-file", string()),
openBrowser: option("--open-browser"),
}),
}
);
const [reporter, config] = run(parser);
Der conditional()-Kombinator nimmt eine Diskriminator-Option (--reporter) und eine Map von Zweigen. Jeder Zweig definiert, welche anderen Optionen für diesen Diskriminatorwert gültig sind.
TypeScript leitet den Ergebnistyp automatisch ab:
type Result =
| ["console", {}]
| ["junit", { outputFile: string }]
| ["html", { outputFile: string; openBrowser: boolean }];
Wenn reporter "junit" ist, ist outputFile string – nicht string | undefined. Die Beziehung ist im Typ kodiert.
Jetzt erhält Ihre Geschäftslogik echte Typsicherheit:
const [reporter, config] = run(parser);
switch (reporter) {
case "console":
runWithConsoleOutput();
break;
case "junit":
// TypeScript weiß, dass config.outputFile ein string ist
writeJUnitReport(config.outputFile);
break;
case "html":
// TypeScript weiß, dass config.outputFile und config.openBrowser existieren
writeHtmlReport(config.outputFile);
if (config.openBrowser) openInBrowser(config.outputFile);
break;
}
Kein Validierungscode. Keine Laufzeitprüfungen. Wenn Sie einen neuen Reporter-Typ hinzufügen und vergessen, ihn im Switch zu behandeln, informiert Sie der Compiler darüber.
Ein komplexeres Beispiel: Datenbankverbindungen
Test-Reporter sind ein schönes Beispiel, aber versuchen wir etwas mit mehr Variation. Datenbankverbindungsstrings:
myapp --db=sqlite --file=./data.db
myapp --db=postgres --host=localhost --port=5432 --user=admin
myapp --db=mysql --host=localhost --port=3306 --user=root --ssl
Jeder Datenbanktyp benötigt völlig unterschiedliche Optionen:
- SQLite benötigt nur einen Dateipfad
- PostgreSQL benötigt Host, Port, Benutzer und optional ein Passwort
- MySQL benötigt Host, Port, Benutzer und hat ein SSL-Flag
So modellieren Sie dies:
import { conditional, object } from "@optique/core/constructs";
import { withDefault, optional } from "@optique/core/modifiers";
import { option } from "@optique/core/primitives";
import { choice, string, integer } from "@optique/core/valueparser";
const dbParser = conditional(
option("--db", choice(["sqlite", "postgres", "mysql"])),
{
sqlite: object({
file: option("--file", string()),
}),
postgres: object({
host: option("--host", string()),
port: withDefault(option("--port", integer()), 5432),
user: option("--user", string()),
password: optional(option("--password", string())),
}),
mysql: object({
host: option("--host", string()),
port: withDefault(option("--port", integer()), 3306),
user: option("--user", string()),
ssl: option("--ssl"),
}),
}
);
Der abgeleitete Typ:
type DbConfig =
| ["sqlite", { file: string }]
| ["postgres", { host: string; port: number; user: string; password?: string }]
| ["mysql", { host: string; port: number; user: string; ssl: boolean }];
Beachten Sie die Details: PostgreSQL verwendet standardmäßig Port 5432, MySQL 3306. PostgreSQL hat ein optionales Passwort, MySQL hat ein SSL-Flag. Jeder Datenbanktyp hat genau die Optionen, die er benötigt – nicht mehr und nicht weniger.
Mit dieser Struktur ist das Schreiben von dbConfig.ssl, wenn der Modus sqlite ist, kein Laufzeitfehler – es ist eine Kompilierzeit-Unmöglichkeit.
Versuchen Sie, dies mit requires_if-Attributen auszudrücken. Das geht nicht. Die Beziehungen sind zu komplex.
Das Muster ist überall
Wenn Sie es einmal sehen, finden Sie dieses Muster in vielen CLI-Tools:
Authentifizierungsmodi:
const authParser = conditional(
option("--auth", choice(["none", "basic", "token", "oauth"])),
{
none: object({}),
basic: object({
username: option("--username", string()),
password: option("--password", string()),
}),
token: object({
token: option("--token", string()),
}),
oauth: object({
clientId: option("--client-id", string()),
clientSecret: option("--client-secret", string()),
tokenUrl: option("--token-url", url()),
}),
}
);
Deployment-Ziele, Ausgabeformate, Verbindungsprotokolle – überall dort, wo Sie einen Modus-Selektor haben, der bestimmt, welche anderen Optionen gültig sind.
Warum conditional() existiert
Optique hat bereits einen or()-Kombinator für sich gegenseitig ausschließende Alternativen. Warum brauchen wir conditional()?
Der or()-Kombinator unterscheidet Zweige basierend auf der Struktur – welche Optionen vorhanden sind. Er funktioniert gut für Unterbefehle wie git commit vs. git push, bei denen sich die Argumente vollständig unterscheiden.
Aber im Reporter-Beispiel ist die Struktur identisch: Jeder Zweig hat ein --reporter-Flag. Der Unterschied liegt im Wert des Flags, nicht in seiner Präsenz.
// Das wird nicht wie beabsichtigt funktionieren
const parser = or(
object({ reporter: option("--reporter", choice(["console"])) }),
object({
reporter: option("--reporter", choice(["junit", "html"])),
outputFile: option("--output-file", string())
}),
);
Wenn Sie --reporter junit übergeben, versucht or(), einen Zweig basierend auf den vorhandenen Optionen auszuwählen. Beide Zweige haben --reporter, daher kann es sie strukturell nicht unterscheiden.
conditional() löst dieses Problem, indem es zuerst den Wert des Diskriminators liest und dann den entsprechenden Zweig auswählt. Es überbrückt die Lücke zwischen strukturellem Parsen und wertbasierten Entscheidungen.
Die Struktur ist die Einschränkung
Anstatt Optionen in einen lockeren Typ zu parsen und dann Beziehungen zu validieren, definieren Sie einen Parser, dessen Struktur die Einschränkung ist.
| Traditioneller Ansatz | Optique-Ansatz |
|---|---|
| Parsen → Validieren → Verwenden | Parsen (mit Einschränkungen) → Verwenden |
| Typen und Validierungslogik werden separat gepflegt | Typen spiegeln die Einschränkungen wider |
| Unstimmigkeiten werden zur Laufzeit gefunden | Unstimmigkeiten werden zur Kompilierzeit gefunden |
Die Parser-Definition wird zur einzigen Quelle der Wahrheit. Fügen Sie einen neuen Reporter-Typ hinzu? Die Parser-Definition ändert sich, der abgeleitete Typ ändert sich, und der Compiler zeigt Ihnen überall, was aktualisiert werden muss.
Probieren Sie es aus
Wenn dies mit einer CLI, die Sie entwickeln, in Resonanz steht:
Wenn Sie das nächste Mal dabei sind, eine if-Anweisung zu schreiben, die Optionsbeziehungen prüft, fragen Sie sich: Könnte der Parser diese Einschränkung stattdessen ausdrücken?
Die Struktur Ihres Parsers ist die Einschränkung. Sie brauchen diesen Validierungscode möglicherweise überhaupt nicht.