23 points par hongminhee 2025-08-23 | 1 commentaires | Partager sur WhatsApp

Bonjour ! Comme je crée souvent des outils CLI en TypeScript, les limites des bibliothèques existantes me laissaient sur ma faim, ce qui m’a amené à développer un nouveau parseur CLI. Je voulais le présenter ici à celles et ceux que ça pourrait intéresser.

En développant des applications CLI, il y avait toujours un point qui me gênait. La plupart des bibliothèques de parsing CLI existantes définissent la structure du CLI via un objet de configuration ou une API impérative, mais avec cette approche, non seulement la sûreté de typage est limitée, mais il est aussi difficile d’exprimer des structures CLI complexes.

En particulier, pour représenter des groupes d’options mutuellement exclusives, il fallait disperser une logique de validation séparée un peu partout. Des contraintes du type « cette option et cette autre ne peuvent pas être utilisées en même temps » ou « dans ce mode, seules certaines options sont autorisées » étaient difficiles à exprimer proprement dans le code. Et même avec TypeScript, il fallait souvent définir manuellement le type du résultat du parsing.

Une approche par combinateurs de parseurs fonctionnels (parser combinator)

Je me suis donc inspiré de optparse-applicative en Haskell pour créer un parseur CLI TypeScript basé sur une approche de combinateurs de parseurs fonctionnels.

Approche classique :

// Approche typique des bibliothèques existantes  
const program = new Command()  
  .option('-p, --port <number>', 'port number')  
  .option('-h, --host <string>', 'hostname')  
  .action((options) => {  
    // Le type de options est any ou doit être défini manuellement  
  });  

Approche Optique :

// On combine de petits parseurs pour construire une structure plus grande  
const serverConfig = object({  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  host: option("-h", "--host", string()),  
  verbose: option("-v", "--verbose")  
});  
  
// TypeScript infère automatiquement le type !  
// { port: number, host: string, verbose: boolean }  
const config = run(serverConfig);  

Différence clé n°1 : exprimer les options mutuellement exclusives dans la structure

Le plus grand point différenciant est la possibilité d’exprimer naturellement des groupes d’options mutuellement exclusives. Les bibliothèques existantes doivent généralement gérer ce type de contrainte avec une logique de validation séparée, alors qu’Optique permet d’intégrer directement la contrainte dans la structure elle-même grâce au combinateur or().

// Mode serveur vs mode client — jeux d’options complètement différents  
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 génère automatiquement une union discriminée  
// { mode: "server", port: number, workers: number, ssl: boolean } |   
// { mode: "client", connect: string, timeout: number, retries: number }  

Avec une bibliothèque classique, il aurait fallu faire cette validation à la main :

// Les lourdeurs de l’approche classique  
if (options.mode === "server" && options.connect) {  
  throw new Error("--connect ne peut pas être utilisé en mode serveur");  
}  
if (options.mode === "client" && options.workers) {  
  throw new Error("--workers ne peut pas être utilisé en mode client");  
}  

Différence clé n°2 : inférence de types entièrement automatique

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")  
  }))  
);  
  
// Le résultat est automatiquement inféré comme une union discriminée  
const result = run(gitLike);  
if (result.type === "add") {  
  // TypeScript affine automatiquement le type  
  console.log(`Adding ${result.files.join(", ")}`);  
}  

Différence clé n°3 : modularité et réutilisabilité

Le combinateur merge() permet de réutiliser des groupes d’options, ce qui facilite le partage d’options communes entre plusieurs commandes.

// Définition de groupes d’options réutilisables  
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()))  
});  
  
// Composition selon les besoins  
const devMode = merge(networkOptions, object({ debug: option("--debug") }));  
const prodMode = merge(networkOptions, authOptions, loggingOptions);  

Différence clé n°4 : validations intégrées riches

Les parseurs de valeurs vont au-delà de la simple conversion de type et fournissent des validations utiles.

const parser = object({  
  // Vérifie l’existence réelle dans le système de fichiers  
  inputFile: option("--input", path({ mustExist: true })),  
  
  // Validation de l’intervalle des numéros de port  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  
  // Restriction des protocoles d’URL  
  api: option("--api", url({ allowedProtocols: ["https:"] })),  
  
  // Restriction des choix possibles  
  logLevel: option("--log", choice(["debug", "info", "warn", "error"]))  
});  

Support des runtimes

  • @optique/core : compatible avec tous les runtimes JavaScript (navigateur, edge functions, etc.)
  • @optique/run : version batteries incluses pour Node.js, Bun et 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  

Pour conclure

Si les bibliothèques CLI existantes suivent une logique de « créer un parseur à partir d’une configuration », Optique adopte une approche fonctionnelle de « construire un grand parseur en combinant de petits parseurs ».

Cette différence apparaît particulièrement clairement lorsqu’il s’agit d’exprimer des groupes d’options mutuellement exclusives. Comme il est possible d’exprimer directement des contraintes CLI complexes dans la structure même du parseur, sans logique de validation séparée, on gagne à la fois en sûreté de typage et en concision du code.

Bien sûr, le projet est encore à un stade précoce de développement, donc l’API peut évoluer, mais si vous avez envie d’apporter l’élégance des combinateurs de parseurs fonctionnels au développement de CLI en TypeScript, cela vaut peut-être le coup d’essayer.

1 commentaires

 
spilist2 2025-08-25

Waouh, c’est super ! Merci du partage. Il faudra que j’essaie aussi quand je créerai une CLI.