23 puntos por hongminhee 2025-08-23 | 1 comentarios | Compartir por WhatsApp

¡Hola! Como suelo crear herramientas CLI con TypeScript con frecuencia, me frustraban las limitaciones de las librerías existentes y terminé creando un nuevo parser de CLI. Quería presentarlo por aquí por si a alguien le interesa.

Mientras desarrollaba aplicaciones CLI, siempre había algo que me resultaba incómodo. La mayoría de las librerías parser de CLI existentes definen la estructura de la CLI mediante objetos de configuración o APIs imperativas, y con ese enfoque no solo se pierde seguridad de tipos, sino que también es difícil expresar estructuras de CLI complejas.

En particular, para representar grupos de opciones mutuamente excluyentes había que dispersar lógica de validación adicional por todas partes. Restricciones como “esta opción y aquella no pueden usarse al mismo tiempo” o “en este modo solo se permiten estas opciones” eran difíciles de expresar de forma limpia en el código. Y aunque se usara TypeScript, en muchos casos había que definir manualmente el tipo del resultado del parseo.

Un enfoque de combinadores de parser funcionales (parser combinator)

Por eso, inspirado en optparse-applicative de Haskell, intenté crear un parser de CLI para TypeScript con un enfoque de combinadores de parser funcionales.

Enfoque tradicional:

// Forma típica de las librerías existentes  
const program = new Command()  
  .option('-p, --port <number>', 'port number')  
  .option('-h, --host <string>', 'hostname')  
  .action((options) => {  
    // El tipo de options es any o hay que definirlo manualmente  
  });  

Enfoque de Optique:

// Se construye una estructura grande combinando parsers pequeños  
const serverConfig = object({  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  host: option("-h", "--host", string()),  
  verbose: option("-v", "--verbose")  
});  
  
// ¡TypeScript infiere el tipo automáticamente!  
// { port: number, host: string, verbose: boolean }  
const config = run(serverConfig);  

Diferencia 1: expresar opciones mutuamente excluyentes como estructura

La diferencia más grande es que permite expresar de forma natural grupos de opciones mutuamente excluyentes. Las librerías existentes suelen manejar este tipo de restricciones con lógica de validación separada, pero Optique puede incorporar la restricción directamente en la estructura usando el combinador or().

// Modo servidor vs modo cliente: conjuntos de opciones completamente distintos  
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 genera automáticamente una discriminated union  
// { mode: "server", port: number, workers: number, ssl: boolean } |   
// { mode: "client", connect: string, timeout: number, retries: number }  

Con una librería tradicional, esta validación tendría que hacerse manualmente:

// Lo engorroso del enfoque tradicional  
if (options.mode === "server" && options.connect) {  
  throw new Error("--connect no se puede usar en modo servidor");  
}  
if (options.mode === "client" && options.workers) {  
  throw new Error("--workers no se puede usar en modo cliente");  
}  

Diferencia 2: inferencia de tipos completamente automática

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")  
  }))  
);  
  
// El resultado se infiere automáticamente como discriminated union  
const result = run(gitLike);  
if (result.type === "add") {  
  // TypeScript hace el narrowing de tipos por su cuenta  
  console.log(`Adding ${result.files.join(", ")}`);  
}  

Diferencia 3: modularidad y reutilización

El combinador merge() permite reutilizar grupos de opciones, por lo que es fácil compartir opciones comunes entre varios comandos.

// Definición de grupos de opciones reutilizables  
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()))  
});  
  
// Combinación según sea necesario  
const devMode = merge(networkOptions, object({ debug: option("--debug") }));  
const prodMode = merge(networkOptions, authOptions, loggingOptions);  

Diferencia 4: validación integrada rica

Los parsers de valores van más allá de la simple conversión de tipos y ofrecen validaciones con significado.

const parser = object({  
  // Verifica si realmente existe en el sistema de archivos  
  inputFile: option("--input", path({ mustExist: true })),  
  
  // Validación del rango del número de puerto  
  port: option("-p", "--port", integer({ min: 1, max: 65535 })),  
  
  // Restricción de protocolos de URL  
  api: option("--api", url({ allowedProtocols: ["https:"] })),  
  
  // Restricción de opciones disponibles  
  logLevel: option("--log", choice(["debug", "info", "warn", "error"]))  
});  

Soporte de runtime

  • @optique/core: compatible con todos los runtimes de JavaScript (navegador, edge functions, etc.)
  • @optique/run: versión con todo incluido para Node.js, Bun y Deno

Instalación:

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  

Para cerrar

Si las librerías CLI existentes siguen un enfoque de “crear parsers a través de configuración”, Optique adopta un enfoque funcional de “crear parsers grandes combinando parsers pequeños”.

Esta diferencia se nota especialmente al expresar grupos de opciones mutuamente excluyentes. Como las restricciones complejas de una CLI pueden expresarse en la propia estructura del parser sin lógica de validación separada, se obtiene al mismo tiempo seguridad de tipos y código más conciso.

Por supuesto, todavía está en una etapa temprana de desarrollo, así que la API puede cambiar, pero creo que vale la pena probarlo si quieren llevar la elegancia de los combinadores de parser funcionales al desarrollo de CLI en TypeScript.

1 comentarios

 
spilist2 2025-08-25

¡Guau, está muy bueno! Gracias por compartirlo. Yo también tendré que probarlo cuando haga un CLI.