Introdução: Por que TypeScript em CLIs?
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.
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.
Fundamentos: Estruturando seu Projeto CLI
Configuração Inicial e Dependências
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.
mkdir meu-cli
cd meu-cli
npm init -y
npm install --save-dev typescript ts-node @types/node
npm install yargs chalk
O yargs é a biblioteca mais robusta para parsing de argumentos com TypeScript, oferecendo validação automática e geração de help text. O chalk fornece cores para output terminal. Aqui está o tsconfig.json essencial:
{
"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"]
}
Estrutura de Pastas e Arquitetura
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:
meu-cli/
├── src/
│ ├── commands/
│ │ ├── deploy.ts
│ │ └── status.ts
│ ├── services/
│ │ └── api.ts
│ ├── types/
│ │ └── index.ts
│ └── index.ts
├── dist/
├── package.json
└── tsconfig.json
Separar commands (interface com o usuário) de services (lógica real) permite reutilizar código em contextos diferentes e facilita testes. O diretório types centraliza suas definições TypeScript, evitando dispersão de interfaces pelo código.
Criando Seu Primeiro CLI Funcional
Um CLI Simples com Yargs
Vamos construir um gerenciador de tarefas básico que demonstra os conceitos fundamentais. Comece pelo arquivo principal:
// src/types/index.ts
export interface Task {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}
export interface CommandResult {
success: boolean;
message: string;
data?: unknown;
}
Agora, crie o serviço que contém a lógica de negócio:
// 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
};
}
};
Agora defina os comandos que serão expostos ao usuário:
// 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);
}
}
Por fim, integre tudo no ponto de entrada:
// 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);
});
Executando e Testando
Adicione scripts no package.json:
{
"scripts": {
"dev": "ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Teste seus comandos:
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
Padrões Avançados e Boas Práticas
Tratamento Robusto de Erros
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.
// 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);
}
};
Agora use isso em seus serviços:
// 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
};
};
Validação de Argumentos com Esquemas
Yargs funciona bem, mas para projetos maiores, adicione validação com Zod para robustez máxima:
npm install zod
// 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);
};
Use na validação:
.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;
}
}
)
Persistência de Dados
CLIs reais precisam persistir dados. Aqui está um exemplo com JSON simples (para produção, considere SQLite ou um banco real):
// 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));
}
};
Integre isso ao seu serviço:
// 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;
}
};
E no seu index.ts:
const main = async () => {
await taskService.initialize();
await setupTaskCommands(yargs(hideBin(process.argv)))
.demandCommand(1)
.strict()
.help()
.parse();
};
Conclusão
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.