Commander und yargs mit echter Typinferenz statt String-Suppe bei Flags und Argumenten
Interne CLI-Tools wachsen fast immer organisch: ein Flag hier, ein Subcommand dort, irgendwann parst niemand mehr genau, was ein Nutzer eingeben darf. Mit Commander und yargs lassen sich Flags, Optionen und Subcommands so deklarieren, dass TypeScript die Handler-Signatur automatisch aus der Definition ableitet, Tippfehler bei Flag-Namen werden zu Compile-Fehlern statt zu stillen undefined-Werten zur Laufzeit.
Inhaltsverzeichnis
- 1. Warum CLI-Argumente ohne Typen ein stiller Bug-Magnet sind
- 2. Commander: Programm, Optionen und Subcommands deklarieren
- 3. yargs: Builder-Pattern mit InferredOptionTypes
- 4. Subcommands mit eigenem, typisiertem Options-Scope
- 5. Laufzeitvalidierung mit zod ergänzen
- 6. Exit-Codes und Fehlerklassen typisiert modellieren
- 7. CLI-Handler isoliert testen
- 8. Paketierung und der Bin-Entry-Point
- 9. Wann sich der Typisierungsaufwand lohnt
- 10. Zusammenfassung
- 11. FAQ
1. Warum CLI-Argumente ohne Typen ein stiller Bug-Magnet sind
Ein klassisches Node-CLI-Skript liest process.argv, parst die Strings von Hand und reicht sie als lose Objekte an die Business-Logik weiter. Jeder Flag-Name ist ein String-Literal, jede Option ein optionales Feld ohne Garantie, dass es tatsächlich existiert. Ein Tippfehler wie --outut statt --output führt nicht zu einem Compile-Fehler, sondern zu einem undefined-Wert, der erst tief in der Logik zu einem kryptischen Laufzeitfehler wird.
Bei einer wachsenden internen CLI mit einem Dutzend Subcommands multipliziert sich dieses Problem: Jeder Handler bekommt sein eigenes, handgeschriebenes Interface für die Optionen, das bei jeder Änderung der Flag-Definition manuell nachgezogen werden muss. Genau hier setzen Commander und yargs mit TypeScript-Typinferenz an, die Optionsdefinition ist die einzige Quelle der Wahrheit, aus der der Typ des Handler-Parameters automatisch folgt.
Der Effekt ist besonders bei Tools spürbar, die von mehreren Entwicklern gepflegt werden: Wer einen Flag umbenennt, sieht sofort an roten Unterstreichungen in der IDE, welche Handler angepasst werden müssen, statt es erst beim nächsten CI-Lauf oder schlimmer beim nächsten Produktivlauf zu bemerken.
// Ohne Typinferenz: manuelles Interface, das leicht auseinanderläuft
interface DeployOptionsManual {
environment?: string;
dryRun?: boolean;
// vergessen: --force wurde vor drei Wochen hinzugefügt
}
function runManual(opts: DeployOptionsManual) {
// opts.force existiert zur Laufzeit, ist aber im Typ unsichtbar
}
2. Commander: Programm, Optionen und Subcommands deklarieren
Commander baut ein Command-Objekt auf, an dem .option() und .argument() verkettet werden. Seit Commander 9 mit TypeScript-Definitionen leitet .opts<T>() den Rückgabetyp aus einem generischen Parameter ab, wenn dieser explizit angegeben wird, was bei komplexeren CLIs deutlich robuster ist als die automatische Inferenz allein.
Für kleinere Tools reicht oft die eingebaute Inferenz aus der Optionskette, bei umfangreichen CLIs mit vielen optionalen und erforderlichen Flags empfiehlt es sich, das Optionsinterface explizit zu definieren und per Generic an opts() zu binden. So bleibt der Handler-Code vollständig typsicher, ohne dass man auf die Inferenz-Heuristik der Bibliothek angewiesen ist.
import { Command } from "commander";
interface DeployOptions {
environment: "staging" | "production";
dryRun: boolean;
force?: boolean;
}
const program = new Command();
program
.name("deploy-cli")
.requiredOption("-e, --environment <env>", "Zielumgebung")
.option("--dry-run", "nur simulieren, nichts ausführen", false)
.option("--force", "Sicherheitsabfrage überspringen")
.action((_opts, cmd) => {
const opts = cmd.optsWithGlobals<DeployOptions>();
// opts.environment ist typsicher "staging" | "production" bekannt
console.log(`Deploy nach ${opts.environment}, dryRun=${opts.dryRun}`);
});
program.parse();
3. yargs: Builder-Pattern mit InferredOptionTypes
yargs verfolgt einen anderen Ansatz als Commander: Die .option()-Aufrufe werden verkettet, und yargs leitet aus dem Builder-Objekt automatisch den Typ des argv-Ergebnisses ab, ganz ohne manuelles Interface. Der Typparameter type pro Option (string, boolean, number, array) fließt direkt in den inferierten Typ ein.
Besonders praktisch ist choices: Wird eine Option auf eine feste Liste von Werten eingeschränkt, inferiert yargs daraus automatisch eine Union aus String-Literalen statt eines generischen string, was Tippfehler bei erlaubten Werten schon im Editor sichtbar macht.
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
const argv = yargs(hideBin(process.argv))
.option("environment", {
alias: "e",
type: "string",
choices: ["staging", "production"] as const,
demandOption: true,
})
.option("dryRun", { type: "boolean", default: false })
.option("retries", { type: "number", default: 3 })
.parseSync();
// argv.environment ist "staging" | "production", nicht bloß string
console.log(argv.environment, argv.dryRun, argv.retries);
4. Subcommands mit eigenem, typisiertem Options-Scope
Sowohl Commander als auch yargs unterstützen Subcommands, bei denen jeder Befehl seinen eigenen Satz an Optionen bekommt. Der Schlüssel zu Typsicherheit ist, jedes Subcommand-Modul ein eigenes Optionsinterface exportieren zu lassen und dieses konsequent an den Handler zu binden, statt ein globales Options-Objekt für alle Subcommands zu teilen.
Bei yargs eignet sich dafür das .command()-Overload mit einem builder-Callback, dessen Rückgabetyp den handler-Parameter automatisch typisiert. Bei Commander wird jedes Subcommand als eigenes Command-Objekt mit eigenem Options-Interface modelliert, was die Trennung noch expliziter macht.
import yargs from "yargs";
interface MigrateArgs {
target: string;
steps: number;
}
yargs(process.argv.slice(2))
.command<MigrateArgs>(
"migrate <target>",
"Datenbankmigration ausführen",
(y) =>
y
.positional("target", { type: "string", demandOption: true })
.option("steps", { type: "number", default: 1 }),
(args) => {
// args ist vollständig als MigrateArgs typisiert
runMigration(args.target, args.steps);
}
)
.demandCommand(1)
.parse();
function runMigration(target: string, steps: number) {
console.log(`Migriere ${target} um ${steps} Schritte`);
}
5. Laufzeitvalidierung mit zod ergänzen
Typinferenz auf Ebene von Commander oder yargs sichert nur die Struktur ab, die die Bibliothek selbst kennt, sie schützt nicht vor semantisch ungültigen Eingaben wie einer negativen Portnummer oder einer nicht existierenden Datei. Für diese Fälle lohnt sich eine zusätzliche Validierungsschicht mit zod, deren Schema gleichzeitig als Typquelle für den restlichen Code dient.
Der Vorteil dieser Kombination: Die CLI-Bibliothek übernimmt Parsing und Hilfetexte, zod übernimmt semantische Validierung mit aussagekräftigen Fehlermeldungen, und beide Typen bleiben synchron, weil der zod-Schema-Typ per z.infer in den Handler einfließt.
import { z } from "zod";
const DeploySchema = z.object({
environment: z.enum(["staging", "production"]),
port: z.number().int().positive().max(65535),
});
type DeployArgs = z.infer<typeof DeploySchema>;
function validateAndRun(raw: unknown): void {
const result = DeploySchema.safeParse(raw);
if (!result.success) {
console.error(result.error.issues.map((i) => i.message).join("\n"));
process.exit(1);
}
execute(result.data);
}
function execute(args: DeployArgs) {
console.log(`Starte auf Port ${args.port} in ${args.environment}`);
}
6. Exit-Codes und Fehlerklassen typisiert modellieren
Ein oft vernachlässigter Teil von CLI-Tools ist der Exit-Code: Skripte, die in CI-Pipelines laufen, müssen zuverlässig zwischen Erfolg, erwartetem Fehler und unerwartetem Absturz unterscheiden. Eine einfache Enum für Exit-Codes plus eine Basis-Fehlerklasse mit zugeordnetem Code macht diese Unterscheidung typsicher und verhindert, dass irgendwo im Code ein magischer Zahlenwert wie process.exit(2) ohne Kontext auftaucht.
Kombiniert mit einem zentralen try/catch um den gesamten CLI-Einstiegspunkt lässt sich so garantieren, dass jeder Fehlerpfad einen klar definierten, dokumentierten Exit-Code erzeugt, statt dass Node bei einer unbehandelten Exception implizit mit Code 1 abbricht.
enum ExitCode {
Success = 0,
ValidationError = 1,
NetworkError = 2,
Unexpected = 70,
}
class CliError extends Error {
constructor(message: string, public readonly code: ExitCode) {
super(message);
}
}
async function main() {
try {
await runCommand();
process.exit(ExitCode.Success);
} catch (err) {
if (err instanceof CliError) {
console.error(err.message);
process.exit(err.code);
}
console.error("Unerwarteter Fehler:", err);
process.exit(ExitCode.Unexpected);
}
}
async function runCommand(): Promise<void> {
throw new CliError("Ungültige Umgebung", ExitCode.ValidationError);
}
7. CLI-Handler isoliert testen
Weil die Handler-Funktionen typisierte Argumente statt roher process.argv-Strings entgegennehmen, lassen sie sich in Unit-Tests direkt mit typisierten Objekten aufrufen, ganz ohne den Parser selbst mitzutesten. Das trennt sauber zwischen Parsing-Logik, die man einmal gegen die Bibliothek testet, und Business-Logik, die man mit vielen Szenarien abdeckt.
In der Praxis bedeutet das: Ein Test ruft execute({ environment: "staging", port: 3000 }) direkt auf, ohne einen Kindprozess zu starten oder process.argv zu mocken, was die Testsuite spürbar schneller und robuster macht.
8. Paketierung und der Bin-Entry-Point
Für eine verteilbare CLI braucht package.json ein bin-Feld, das auf die kompilierte JavaScript-Datei zeigt, und diese Datei braucht die Shebang-Zeile #!/usr/bin/env node ganz oben. Wichtig ist, dass der TypeScript-Compiler diese Zeile beim Build nicht entfernt, viele Build-Setups fügen sie stattdessen per Postbuild-Skript hinzu.
Wer die CLI öffentlich auf npm veröffentlicht, sollte zusätzlich die Typdefinitionen der Optionsinterfaces exportieren, falls andere Pakete die CLI programmatisch statt über die Kommandozeile aufrufen wollen, das spart Konsumenten eine doppelte Typdefinition.
{
"name": "deploy-cli",
"bin": {
"deploy-cli": "./dist/cli.js"
},
"scripts": {
"build": "tsc && chmod +x dist/cli.js"
}
}
9. Wann sich der Typisierungsaufwand lohnt
Für ein Einmal-Skript mit zwei Flags ist der Aufwand einer vollen Typinferenz-Pipeline mit zod-Validierung und Exit-Code-Enum überdimensioniert, ein paar destrukturierte argv-Werte reichen. Sobald ein CLI-Tool aber von mehreren Entwicklern gepflegt wird, mehrere Subcommands hat oder in CI-Pipelines eingebunden ist, zahlt sich jede investierte Stunde in Typsicherheit mehrfach zurück.
Der pragmatische Mittelweg, den viele Teams fahren: Commander oder yargs für Parsing und Hilfetexte, ein schlankes zod-Schema für semantische Validierung an den kritischen Stellen, und ein zentrales Fehler- und Exit-Code-Konzept, das von Anfang an mitgedacht wird statt später nachgerüstet.
| Merkmal | Commander | yargs | Reine argv-Parsing |
|---|---|---|---|
| Typinferenz aus Optionen | opts<T>() mit Generic |
automatisch aus Builder | keine, manuell |
| Subcommands | eigene Command-Objekte | .command() mit Builder |
manuell verzweigt |
| Choices als Union-Typ | nicht nativ | ja, über as const |
nicht vorhanden |
| Hilfetext-Generierung | automatisch | automatisch | manuell |
| Bundle-Größe | klein | mittel | minimal |
Mironsoft
TypeScript-Migration, Typsicherheit und Team-Onboarding
JavaScript-Codebasis ohne Typsicherheit, aber keine Zeit für eine Rundum-Migration?
Wir migrieren bestehende JavaScript-Projekte schrittweise zu TypeScript, richten strikte Compiler-Einstellungen sauber ein und bringen Teams mit Code-Reviews und Style-Guides auf denselben Typsicherheits-Stand.
Migrations-Fahrplan
Schrittweise JS-zu-TS-Migration ohne Big-Bang-Risiko planen und umsetzen.
Strict-Mode-Einführung
tsconfig.json, ESLint-Regeln und CI-Checks für dauerhafte Typsicherheit aufsetzen.
Team-Onboarding
Entwickler mit Workshops und Code-Reviews in TypeScript-Best-Practices einarbeiten.
10. Zusammenfassung
CLI-Tools
Bibliotheken
Commander und yargs mit generischer Typinferenz für Optionen
Validierung
zod-Schema als zusätzliche Laufzeitprüfung und Typquelle
Fehlerpfade
Exit-Code-Enum plus zentrale CliError-Klasse
Testbarkeit
Handler nehmen typisierte Objekte, keine argv-Mocks nötig