Optique: um parser de CLI type-safe para TypeScript
(optique.dev)Olá! Como costumo criar ferramentas de CLI com TypeScript com frequência, senti falta diante das limitações das bibliotecas existentes e acabei criando um novo parser de CLI. Resolvi compartilhar aqui para quem tiver interesse.
Sempre houve um ponto incômodo ao desenvolver aplicações de CLI. A maioria das bibliotecas de parser de CLI existentes define a estrutura da CLI com objetos de configuração ou APIs imperativas, e isso dificulta não só a segurança de tipos como também a representação de estruturas de CLI mais complexas.
Em especial, para expressar grupos de opções mutuamente exclusivos, era preciso espalhar lógica de validação separada por vários lugares. Restrições como “esta opção e aquela não podem ser usadas ao mesmo tempo” ou “neste modo apenas estas opções são permitidas” eram difíceis de representar de forma limpa no código. E, mesmo usando TypeScript, muitas vezes ainda era necessário definir manualmente o tipo do resultado do parsing.
A abordagem de combinadores de parser funcionais (parser combinator)
Então, inspirado no optparse-applicative do Haskell, criei um parser de CLI para TypeScript usando a abordagem de combinadores de parser funcionais.
Abordagem tradicional:
// Forma típica das bibliotecas existentes
const program = new Command()
.option('-p, --port <number>', 'port number')
.option('-h, --host <string>', 'hostname')
.action((options) => {
// o tipo de options é any ou precisa ser definido manualmente
});
Abordagem do Optique:
// Combina parsers pequenos para criar uma estrutura maior
const serverConfig = object({
port: option("-p", "--port", integer({ min: 1, max: 65535 })),
host: option("-h", "--host", string()),
verbose: option("-v", "--verbose")
});
// O TypeScript infere o tipo automaticamente!
// { port: number, host: string, verbose: boolean }
const config = run(serverConfig);
Diferencial 1: expressar opções mutuamente exclusivas na própria estrutura
O maior diferencial é que dá para expressar grupos de opções mutuamente exclusivas de forma natural. Em bibliotecas tradicionais, esse tipo de restrição precisa ser tratado com lógica de validação separada; no Optique, é possível incorporar a restrição à própria estrutura usando o combinador or().
// Modo servidor vs modo cliente - conjuntos de opções completamente diferentes
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())
})
);
// O TypeScript gera automaticamente uma união discriminada
// { mode: "server", port: number, workers: number, ssl: boolean } |
// { mode: "client", connect: string, timeout: number, retries: number }
Em uma biblioteca tradicional, essa validação provavelmente teria de ser feita manualmente:
// A complicação da abordagem tradicional
if (options.mode === "server" && options.connect) {
throw new Error("--connect não pode ser usado no modo servidor");
}
if (options.mode === "client" && options.workers) {
throw new Error("--workers não pode ser usado no modo cliente");
}
Diferencial 2: inferência de tipos totalmente 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")
}))
);
// O resultado é inferido automaticamente como união discriminada
const result = run(gitLike);
if (result.type === "add") {
// O TypeScript faz o narrowing de tipo automaticamente
console.log(`Adding ${result.files.join(", ")}`);
}
Diferencial 3: modularidade e reutilização
Com o combinador merge(), é possível reutilizar grupos de opções, facilitando o compartilhamento de opções comuns entre vários comandos.
// Definição de grupo de opções reutilizável
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()))
});
// Composição conforme a necessidade
const devMode = merge(networkOptions, object({ debug: option("--debug") }));
const prodMode = merge(networkOptions, authOptions, loggingOptions);
Diferencial 4: validação embutida rica
Os parsers de valor vão além da simples conversão de tipo e oferecem validações úteis.
const parser = object({
// Verifica se o arquivo realmente existe no sistema de arquivos
inputFile: option("--input", path({ mustExist: true })),
// Validação do intervalo de portas
port: option("-p", "--port", integer({ min: 1, max: 65535 })),
// Restrição de protocolo da URL
api: option("--api", url({ allowedProtocols: ["https:"] })),
// Restrição de escolhas
logLevel: option("--log", choice(["debug", "info", "warn", "error"]))
});
Suporte a runtime
- @optique/core: suporte a todos os runtimes JavaScript (browser, edge functions etc.)
- @optique/run: versão com tudo incluído para Node.js, Bun e Deno
Instalação:
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
Encerrando
Se as bibliotecas de CLI existentes seguem uma abordagem de “criar o parser por meio de configuração”, o Optique segue uma abordagem funcional de “combinar parsers pequenos para criar um parser maior”.
Essa diferença aparece de forma especialmente clara ao expressar grupos de opções mutuamente exclusivas. Como restrições complexas de CLI podem ser representadas pela própria estrutura do parser, sem lógica de validação separada, dá para obter ao mesmo tempo segurança de tipos e código mais conciso.
Claro, ainda está em estágio inicial de desenvolvimento e a API pode mudar, mas, para quem quiser trazer a elegância dos combinadores de parser funcionais para o desenvolvimento de CLIs em TypeScript, vale a pena experimentar.
- GitHub: https://github.com/dahlia/optique
- Documentação: https://optique.dev/
- npm: https://www.npmjs.com/package/@optique/core
- JSR: https://jsr.io/@optique/core
1 comentários
Uau, que legal! Obrigado por compartilhar. Também vou tentar usar quando eu criar uma CLI.