TypeScript pour construire un CLI
J’ai écrit pas mal d’outils en ligne de commande au fil des ans, en shell, en Node.js, même en Python. Mais depuis que je me suis sérieusement mis à TypeScript, le CLI est devenu mon terrain de jeu préféré pour ce langage. Pourquoi? Parce qu’un CLI est petit, autonome, et pourtant il brasse toutes les problématiques qui rendent TypeScript intéressant: typage des arguments, gestion des erreurs, parsing, tests. Dans cet article, je raconte comment je construis mes CLIs en TypeScript, de la structure minimale jusqu’au déploiement global.
Pourquoi TypeScript pour un CLI ?
Un CLI, à la base, peut se faire en JavaScript pur ou même en shell. Qu’est-ce que TypeScript apporte ?
Type safety sur un sujet piégeux: les arguments
Le premier écueil de tout CLI, c’est le parsing des arguments de ligne de commande. On reçoit des chaînes de caractères brutes et il faut leur donner du sens: --port 8080 doit devenir un nombre, --verbose un booléen, list app:prod une sous-commande avec une cible. En JavaScript pur, on se retrouve à écrire des vérifications manuelles à chaque fois:
// JavaScript: verification manuelle, source d'erreurs |
Avec TypeScript et une bibliothèque de parsing, le type du port est déjà correct, et le compilateur me dit si je fais une erreur avant même d’exécuter le CLI:
// parsed.port est deja un number si la bibliotheque est correctement typée |
Partager des types avec un serveur
Un scénario que j’utilise souvent: le même outil sert de CLI de gestion et de client pour une API. Si le CLI parle à un serveur que j’écris aussi, je peux partager les types entre les deux:
// types.ts - partagé entre CLI et serveur |
Une seule source de vérité pour les types. Quand le serveur fait évoluer sa structure, le CLI échoue à la compilation au lieu de produire des erreurs obscures au runtime.
Meilleure expérience de développement
L’autocomplétion, la navigation entre définitions, le refactoring: le même confort que pour une application web. Pour un outil qui vit longtemps, c’est un avantage net.
La structure de base
Un CLI TypeScript, c’est finalement peu de fichiers:
mon-cli/ |
Le package.json: la partie la plus importante
Le package.json fait trois choses essentielles: déclarer le point d’entrée binaire (bin), définir le shebang, et fournir les scripts de build.
{ |
Le champ bin est le coeur: il dit à npm “quand on installe ce paquet, crée une commande moncli qui pointe vers ./dist/index.js“. Le nom dans bin devient le nom de la commande que les gens taperont. À noter:
"type": "module": on utilise les modules ES.- Le shebang doit être la première ligne de
src/index.ts(voir plus bas). tsxpour le développement: il exécute directement du TypeScript sans build intermédiaire, ce qui permet de tester le CLI avecnpm run dev -- --port 8080.
Le shebang et l’exécutable
Un CLI Node.js doit être exécutable. Deux choses sont nécessaires.
D’abord, le shebang, une ligne spéciale en tout début de fichier qui dit au kernel quel interpréteur utiliser:
|
Sans elle, lancer ./dist/index.js en direct échouerait. Ensuite, le fichier doit avoir les permissions d’exécution, qu’on pose après le build:
# Après le build |
Quand le paquet est installé globalement (npm i -g moncli), npm crée automatiquement un lien symbolique exécutable dans le PATH. C’est pour ça que bin + shebang sont essentiels: c’est eux qui rendent la commande invocable n’importe où.
Le parsing des arguments
J’utilise Commander comme bibliothèque de référence, car elle gère le --help, le --version, les flags et les sous-commandes avec très peu de code:
// src/index.ts |
Ce que commander fait pour moi:
- Génère le
--helpautomatiquement, avec la description de chaque sous-commande et de chaque option. - Gère
--version(il litversiondu package.json). - Parse les arguments positionnels (
<service>) et les options (--environment,-e). - Convertit les types:
.option('... <port>', '...', parseInt)transforme la chaîne en nombre. - Gère les erreurs de syntaxe avec un message clair et un code de sortie non nul.
L’alternative principale est Yargs, plus lourd mais très configurable, avec un typage intégré de toutes les options. Pour la majorité des cas, commander suffit. Pour une seule commande et une ou deux options, un parsing manuel bien validé est aussi acceptable, mais dès qu’il y a des sous-commandes, je reviens à commander.
Les flags typés et la sortie colorée
Flags avec types corrects
Le typage des options est la raison d’être de l’intérêt de TS ici. Voyons un exemple où le type évite un bug réel. Commander avec parseInt pour un port:
import { Command, InvalidArgumentError } from 'commander'; |
Sortie colorée
La couleur rend la lecture bien plus agréable, surtout pour les erreurs et les succès. Chalk est le classique:
import chalk from 'chalk'; |
Un retour d’expérience: quand la sortie est redirigée vers un fichier ou un pipe, les codes de couleur polluent le résultat. Chalk (et la plupart des bibliothèques) désactive les couleurs automatiquement si la sortie n’est pas un terminal (
!process.stdout.isTTY). Vérifiez donc que vos helpers passent par la bibliothèque et n’écrivent pas les codes ANSI à la main. Pour les pipelines, proposez aussi un drapeau--no-coloret--no-emoji.
Les codes de sortie et la gestion d’erreurs
Un CLI correct doit retourner un code de sortie: 0 en cas de succès, non nul en cas d’erreur. C’est ce que les scripts shell, les CI et les tests vérifient.
import { Command } from 'commander'; |
Trucs importants:
program.parseAsync: pour les handlers async.program.parseest synchrone et n’attend pas les promesses.process.exitCode = 1plutôt queprocess.exit(1): le premier laisse la sortie se terminer proprement (flush des buffers), le second tue tout immédiatement, ce qui peut tronquer la sortie.process.stderrpour les erreurs,process.stdoutpour les données: on écrit les erreurs surstderrpour que les pipes ne mélangent pas données et erreurs.
function usageError(msg: string) { |
Lire stdin et gérer l’asynchrone
Un CLI doit souvent lire l’entrée standard: soit interactive (prompts), soit via un pipe (cat data.json | moncli import).
Lire stdin complet (pour les pipes)
function readStdin(): Promise<string> { |
Des prompts interactifs
Pour un workflow interactif, Inquirer accompagne commander parfaitement:
import inquirer from 'inquirer'; |
Un retour d’expérience: pour une action destructive en production, ne vous contentez jamais d’une confirmation simple. Demandez la saisie du nom exact du service pour éviter les erreurs de frappe grossières.
Le build: de TypeScript à l’exécutable
La config TypeScript
// tsconfig.json |
Points clés:
strict: true: obligatoire pour tirer le bénéfice du typage. Sans lui, le CLI se fait envahir deanysilencieux.outDir: dist: le code compilé va dansdist.module: NodeNext+moduleResolution: NodeNext: avec"type": "module"dans package.json, il faut des imports avec l’extension.jsmême en TypeScript source:
// APRES compilation, il faut .js |
C’est un détail qui piège beaucoup de monde au premier build. Avec NodeNext, les imports relatifs doivent mentionner .js (l’extension du fichier compilé), pas .ts.
Deux stratégies de build
- tsc (simple): le compilateur standard de TypeScript. Sortie en
dist, lisible, largement suffisant pour 99 % des CLIs.
npm run build |
- esbuild / bun (rapide, bundle): pour un fichier unique autonome. esbuild est très rapide (millisecondes) et peut bundler toutes les dépendances dans un seul fichier:
esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js="#!/usr/bin/env node" |
Et Bun peut exécuter du TypeScript directement, sans étape de build.
Installer globalement et tester
# Depuis le dossier du projet |
npm link crée un lien symbolique dans le PATH, pointant vers votre build local. Idéal pendant le développement. Pour une distribution : npm pack ou publier sur un registre interne, puis npm i -g.
Les tests d’un CLI
Tester un CLI, c’est avant tout tester que les commandes se comportent correctement avec des arguments variés. L’approche la plus robuste: lancer le CLI comme un vrai processus avec le test runner de Node ou Vitest:
import { describe, it, expect } from 'vitest'; |
Tester via execFile plutôt qu’en appelant les fonctions directement teste le vrai comportement: le parsing des arguments, le code de sortie, la sortie. C’est ce qui garantit que le CLI “de l’extérieur” se comporte comme prévu.
Consoles UX: les bonnes pratiques
Quelques habitudes qui transforment un CLI utilisable en un CLI agréable:
- Toujours un
--helpclair avec la description de chaque commande et option. Commander le génère si on remplit les descriptions. - Toujours un
--version: vital pour le debugging. - Des messages d’erreur explicites: “Port invalide: abc, attendu un nombre entre 1 et 65535”, pas juste “erreur”.
- Pas d’emoji dans les pipelines: les emojis sont mal rendus dans les logs de CI. Utilisez des symboles ASCII ou un flag
--no-emoji. --verbose/--quiet: contrôler la verbosité est essentiel pour l’intégration dans des scripts.
Un exemple complet minimal
Voici l’exemple le plus complet possible dans un seul fichier, montrant le shebang, le parsing, une sous-commande, la sortie et les codes de sortie:
|
Et le build qui va avec:
npm run build |
Pourquoi je persiste avec TypeScript
Après plusieurs CLIs, mon retour d’expérience est net: TypeScript transforme le développement de CLI d’un exercice de debug du parsing des arguments en un exercice de définition du bon modèle de commandes. Le typage des options, le partage des types avec un serveur, et l’autocomplétion me font gagner un temps considérable, même sur un outil de 200 lignes.
Le coût est un build supplémentaire (tsc ou esbuild) et une étape de plus entre le code et l’exécution. Mais avec tsx en dev (qui exécute le TS directement), ce coût disparaît presque, et le build final est trivial.
Si vous écrivez un CLI qui va vivre un peu, que ce soit pour votre usage ou celui de votre équipe, TypeScript est, à mon avis, le choix le plus raisonnable.
Pour aller plus loin
- Commander.js et Yargs, les deux grands du parsing.
- Chalk pour la couleur, Inquirer pour les prompts.
- esbuild et Bun pour exécuter/bundler du TypeScript.
- Mon article sur le TypeScript avancé pour les techniques de typage plus poussées.