<h2>Introdução: Por que TypeScript em CLIs?</h2>
<p>Quando você desenvolve aplicações de linha de comando (CLIs), trabalha com código que interage diretamente com usuários através do terminal. Diferentemente de aplicações web, onde você tem abstrações robustas e bibliotecas estabelecidas, CLIs exigem uma comunicação direta: parsing de argumentos, validação de entrada, tratamento de erros em tempo real. TypeScript traz segurança de tipo a esse cenário, capturando erros em tempo de compilação em vez de falhas em produção.</p>
<p>A tipagem estática em TypeScript permite que você construa CLIs mais confiáveis, com autocompletar e refatoração segura. Você saberá exatamente qual forma seus argumentos devem ter, quais campos são opcionais, e como estruturar a resposta para o usuário. Isso reduz bugs e torna a manutenção significativamente mais fácil quando você retorna ao código meses depois.</p>
<h2>Fundamentos: Estruturando seu Projeto CLI</h2>
<h3>Configuração Inicial e Dependências</h3>
<p>Antes de escrever a primeira linha de código, você precisa configurar seu ambiente. Um projeto CLI em TypeScript típico requer ferramentas de build, um runner para executar durante desenvolvimento, e libraries para parsing de argumentos.</p>
<pre><code class="language-bash">mkdir meu-cli
cd meu-cli
npm init -y
npm install --save-dev typescript ts-node @types/node
npm install yargs chalk</code></pre>
<p>O <code>yargs</code> é a biblioteca mais robusta para parsing de argumentos com TypeScript, oferecendo validação automática e geração de help text. O <code>chalk</code> fornece cores para output terminal. Aqui está o <code>tsconfig.json</code> essencial:</p>
<pre><code class="language-json">{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true
},
"include": ["src/*/"],
"exclude": ["node_modules"]
}</code></pre>
<h3>Estrutura de Pastas e Arquitetura</h3>
<p>A organização do projeto impacta diretamente na escalabilidade. Para um CLI pequeno, uma estrutura funcional é suficiente; para CLIs complexas com múltiplos comandos, você precisa de separação clara entre lógica de negócio e interface:</p>
<pre><code>meu-cli/
├── src/
│ ├── commands/
│ │ ├── deploy.ts
│ │ └── status.ts
│ ├── services/
│ │ └── api.ts
│ ├── types/
│ │ └── index.ts
│ └── index.ts
├── dist/
├── package.json
└── tsconfig.json</code></pre>
<p>Separar <code>commands</code> (interface com o usuário) de <code>services</code> (lógica real) permite reutilizar código em contextos diferentes e facilita testes. O diretório <code>types</code> centraliza suas definições TypeScript, evitando dispersão de interfaces pelo código.</p>
<h2>Criando Seu Primeiro CLI Funcional</h2>
<h3>Um CLI Simples com Yargs</h3>
<p>Vamos construir um gerenciador de tarefas básico que demonstra os conceitos fundamentais. Comece pelo arquivo principal:</p>
<pre><code class="language-typescript">// src/types/index.ts
export interface Task {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}
export interface CommandResult {
success: boolean;
message: string;
data?: unknown;
}</code></pre>
<p>Agora, crie o serviço que contém a lógica de negócio:</p>
<pre><code class="language-typescript">// src/services/taskService.ts
import { Task, CommandResult } from '../types/index';
let tasks: Task[] = [];
let nextId: number = 1;
export const taskService = {
addTask(title: string): CommandResult {
if (!title.trim()) {
return { success: false, message: 'Título não pode estar vazio' };
}
const newTask: Task = {
id: nextId++,
title,
completed: false,
createdAt: new Date()
};
tasks.push(newTask);
return {
success: true,
message: Tarefa "${title}" adicionada com sucesso,
data: newTask
};
},
listTasks(): CommandResult {
return {
success: true,
message: Total de ${tasks.length} tarefas,
data: tasks
};
},
completeTask(id: number): CommandResult {
const task = tasks.find(t => t.id === id);
if (!task) {
return { success: false, message: Tarefa ${id} não encontrada };
}
task.completed = true;
return {
success: true,
message: Tarefa ${id} marcada como concluída,
data: task
};
}
};</code></pre>
<p>Agora defina os comandos que serão expostos ao usuário:</p>
<pre><code class="language-typescript">// src/commands/taskCommands.ts
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import chalk from 'chalk';
import { taskService } from '../services/taskService';
export const setupTaskCommands = (argv: yargs.Argv) => {
return argv
.command(
'add <title>',
'Adicionar uma nova tarefa',
(yargs) => yargs.positional('title', {
describe: 'Título da tarefa',
type: 'string'
}),
(args) => {
const result = taskService.addTask(args.title as string);
handleResult(result);
}
)
.command(
'list',
'Listar todas as tarefas',
() => {},
() => {
const result = taskService.listTasks();
if (result.success && Array.isArray(result.data)) {
console.log(chalk.blue('=== Tarefas ==='));
(result.data as any[]).forEach(task => {
const status = task.completed ? chalk.green('✓') : chalk.red('✗');
console.log(${status} [${task.id}] ${task.title});
});
} else {
console.log(chalk.yellow(result.message));
}
}
)
.command(
'complete <id>',
'Marcar tarefa como concluída',
(yargs) => yargs.positional('id', {
describe: 'ID da tarefa',
type: 'number'
}),
(args) => {
const result = taskService.completeTask(args.id as number);
handleResult(result);
}
);
};
function handleResult(result: any): void {
if (result.success) {
console.log(chalk.green(✓ ${result.message}));
} else {
console.log(chalk.red(✗ ${result.message}));
process.exit(1);
}
}</code></pre>
<p>Por fim, integre tudo no ponto de entrada:</p>
<pre><code class="language-typescript">// src/index.ts
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { setupTaskCommands } from './commands/taskCommands';
const main = async () => {
await setupTaskCommands(yargs(hideBin(process.argv)))
.demandCommand(1, 'Você deve especificar um comando')
.strict()
.help()
.parse();
};
main().catch((error) => {
console.error('Erro:', error.message);
process.exit(1);
});</code></pre>
<h3>Executando e Testando</h3>
<p>Adicione scripts no <code>package.json</code>:</p>
<pre><code class="language-json">{
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}</code></pre>
<p>Teste seus comandos:</p>
<pre><code class="language-bash">npm run dev add "Estudar TypeScript"
npm run dev add "Preparar apresentação"
npm run dev list
npm run dev complete 1
npm run dev list</code></pre>
<h2>Padrões Avançados e Boas Práticas</h2>
<h3>Tratamento Robusto de Erros</h3>
<p>Em CLIs, o tratamento de erros não é apenas sobre capturar exceções. Você precisa validar entrada do usuário, comunicar problemas claramente e permitir recuperação quando possível.</p>
<pre><code class="language-typescript">// src/utils/errorHandler.ts
import chalk from 'chalk';
export class CLIError extends Error {
constructor(
public message: string,
public exitCode: number = 1
) {
super(message);
}
}
export const handleError = (error: unknown): void => {
if (error instanceof CLIError) {
console.error(chalk.red(Erro: ${error.message}));
process.exit(error.exitCode);
} else if (error instanceof Error) {
console.error(chalk.red(Erro inesperado: ${error.message}));
if (process.env.DEBUG) {
console.error(error.stack);
}
process.exit(1);
} else {
console.error(chalk.red('Erro desconhecido'));
process.exit(1);
}
};</code></pre>
<p>Agora use isso em seus serviços:</p>
<pre><code class="language-typescript">// Exemplo de validação robusta
export const deleteTask = (id: number): CommandResult => {
if (!Number.isInteger(id) || id <= 0) {
throw new CLIError('ID deve ser um número inteiro positivo');
}
const taskIndex = tasks.findIndex(t => t.id === id);
if (taskIndex === -1) {
throw new CLIError(Tarefa com ID ${id} não encontrada);
}
const [deletedTask] = tasks.splice(taskIndex, 1);
return {
success: true,
message: Tarefa "${deletedTask.title}" removida,
data: deletedTask
};
};</code></pre>
<h3>Validação de Argumentos com Esquemas</h3>
<p>Yargs funciona bem, mas para projetos maiores, adicione validação com Zod para robustez máxima:</p>
<pre><code class="language-bash">npm install zod</code></pre>
<pre><code class="language-typescript">// src/types/schemas.ts
import { z } from 'zod';
export const AddTaskSchema = z.object({
title: z.string().min(1, 'Título não pode estar vazio').max(200),
priority: z.enum(['low', 'medium', 'high']).optional().default('medium')
});
export type AddTaskInput = z.infer<typeof AddTaskSchema>;
export const validateAddTask = (data: unknown): AddTaskInput => {
return AddTaskSchema.parse(data);
};</code></pre>
<p>Use na validação:</p>
<pre><code class="language-typescript">.command(
'add <title>',
'Adicionar tarefa com prioridade',
(yargs) => yargs
.positional('title', { type: 'string' })
.option('priority', {
type: 'string',
choices: ['low', 'medium', 'high'],
default: 'medium'
}),
(args) => {
try {
const validated = validateAddTask({
title: args.title,
priority: args.priority
});
const result = taskService.addTask(validated.title, validated.priority);
handleResult(result);
} catch (error) {
if (error instanceof z.ZodError) {
console.error(chalk.red('Validação falhou:'));
error.errors.forEach(err => {
console.error( - ${err.path.join('.')}: ${err.message});
});
process.exit(1);
}
throw error;
}
}
)</code></pre>
<h3>Persistência de Dados</h3>
<p>CLIs reais precisam persistir dados. Aqui está um exemplo com JSON simples (para produção, considere SQLite ou um banco real):</p>
<pre><code class="language-typescript">// src/services/storage.ts
import fs from 'fs/promises';
import path from 'path';
import { Task } from '../types/index';
const DATA_DIR = path.join(process.cwd(), '.task-cli-data');
const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
export const storage = {
async loadTasks(): Promise<Task[]> {
try {
const data = await fs.readFile(TASKS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
},
async saveTasks(tasks: Task[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2));
}
};</code></pre>
<p>Integre isso ao seu serviço:</p>
<pre><code class="language-typescript">// src/services/taskService.ts
import { storage } from './storage';
let tasks: Task[] = [];
export const taskService = {
async initialize(): Promise<void> {
tasks = await storage.loadTasks();
},
async addTask(title: string): Promise<CommandResult> {
// ... lógica anterior ...
await storage.saveTasks(tasks);
return result;
}
};</code></pre>
<p>E no seu <code>index.ts</code>:</p>
<pre><code class="language-typescript">const main = async () => {
await taskService.initialize();
await setupTaskCommands(yargs(hideBin(process.argv)))
.demandCommand(1)
.strict()
.help()
.parse();
};</code></pre>
<h2>Conclusão</h2>
<p>Você aprendeu que construir CLIs com TypeScript não é sobre usar a ferramenta mais moderna, mas sobre estruturar código legível e seguro desde o início. A tipagem estática captura erros cedo, a separação entre commands e services torna o código reutilizável, e padrões de validação robustos economizam debugging depois. Na prática, a maioria dos problemas em CLIs vem de entrada de usuário mal validada, então invista tempo em schemas de validação. Finalmente, sempre persista dados de forma previsível—não deixe seus usuários à deriva.</p>
<h2>Referências</h2>
<ul>
<li><a href="https://yargs.js.org/" target="_blank" rel="noopener noreferrer">Yargs Documentation</a></li>
<li><a href="https://www.typescriptlang.org/docs/" target="_blank" rel="noopener noreferrer">TypeScript Handbook</a></li>
<li><a href="https://zod.dev/" target="_blank" rel="noopener noreferrer">Zod - TypeScript-first Schema Validation</a></li>
<li><a href="https://nodejs.org/api/fs.html" target="_blank" rel="noopener noreferrer">Node.js fs Documentation</a></li>
<li><a href="https://github.com/chalk/chalk" target="_blank" rel="noopener noreferrer">Chalk - Terminal String Styling</a></li>
</ul>
<p><!-- FIM --></p>