23 pontos por hongminhee 2025-08-23 | 1 comentários | Compartilhar no WhatsApp

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.

1 comentários

 
spilist2 2025-08-25

Uau, que legal! Obrigado por compartilhar. Também vou tentar usar quando eu criar uma CLI.