Eigene CLI-Tools typsicher mit TypeScript bauen
AI generated
type
TypeScript · CLI · Tooling
CLI-Tools typsicher bauen
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.

10 Min. Lesezeit Commander yargs Node.js CLI

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

11. FAQ: CLI-Tools

1Ist Commander oder yargs besser für TypeScript geeignet?
Beide bieten brauchbare Typinferenz, yargs leitet Typen automatischer aus dem Builder ab, während Commander mit einem expliziten Generic an opts() oft präzisere Ergebnisse liefert. Für kleine Tools ist yargs schneller startklar, für komplexe Subcommand-Bäume liefert Commander eine klarere Struktur.
2Brauche ich zusätzlich zod, wenn ich schon yargs-Typen habe?
Ja, wenn semantische Regeln jenseits der reinen Typstruktur gelten, etwa Wertebereiche oder Dateipfade, die existieren müssen. yargs und Commander prüfen nur, ob ein Flag vom richtigen JavaScript-Typ ist, nicht ob der Wert inhaltlich sinnvoll ist.
3Wie gehe ich mit optionalen Flags um, die einen Default haben?
Setze den Default direkt in der Optionsdefinition der Bibliothek, dann inferiert TypeScript automatisch einen nicht-optionalen Typ für dieses Feld, weil zur Laufzeit garantiert ein Wert vorhanden ist.
4Lohnt sich eine CLI-Bibliothek für ein Skript mit nur einem Flag?
Selten, ein einfaches destrukturiertes process.argv reicht meist aus. Der Mehrwert von Commander oder yargs zeigt sich erst ab mehreren Optionen, Subcommands oder wenn automatisch generierte Hilfetexte gebraucht werden.
5Wie teste ich, dass die CLI die richtigen Exit-Codes liefert?
Am zuverlässigsten mit einem Integrationstest, der die kompilierte CLI als Kindprozess startet und den tatsächlichen process.exitCode prüft, ergänzt um Unit-Tests der Handler-Funktionen für die Business-Logik selbst.
6Kann ich Subcommand-Optionen mit globalen Optionen kombinieren?
Ja, beide Bibliotheken unterstützen globale Optionen, die auf allen Subcommands verfügbar sind. Bei Commander über optsWithGlobals(), bei yargs indem globale Optionen vor dem ersten command()-Aufruf definiert werden.
7Was passiert, wenn ein Nutzer ein unbekanntes Flag angibt?
Beide Bibliotheken brechen standardmäßig mit einer Fehlermeldung ab, sofern strict mode aktiv ist. Bei yargs lässt sich das über .strict() erzwingen, bei Commander ist es das Standardverhalten für nicht deklarierte Optionen.
8Muss ich die CLI in JavaScript kompilieren oder geht auch direkte Ausführung?
Für Produktivbetrieb wird meist zu dist/ kompiliert, während der Entwicklung läuft die CLI oft direkt über tsx oder ts-node, um den Build-Schritt bei jeder Iteration zu sparen.
9Wie gehe ich mit Versionsangaben in der CLI um?
Beide Bibliotheken bieten eine .version()-Methode, die den Wert typischerweise aus package.json liest. Wichtig ist, diesen Wert beim Build einzufrieren, statt ihn zur Laufzeit dynamisch aus dem Dateisystem zu lesen, falls die CLI global installiert wird.
10Sollte jede interne CLI öffentlich auf npm veröffentlicht werden?
Nein, für rein interne Tools reicht ein privates Paket oder ein Skript im Monorepo, das über ein Workspace-bin-Feld verfügbar gemacht wird. Öffentliche Veröffentlichung lohnt sich erst, wenn externe Teams das Tool tatsächlich nutzen sollen.