¡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.
- GitHub: https://github.com/dahlia/optique
- Documentación: https://optique.dev/
- npm: https://www.npmjs.com/package/@optique/core
- JSR: https://jsr.io/@optique/core
1 comentarios
¡Guau, está muy bueno! Gracias por compartirlo. Yo también tendré que probarlo cuando haga un CLI.