A assinatura da sua função diz que retorna um User. Não retorna. Ela retorna um User ou explode. O sistema de tipos simplesmente não sabe sobre o segundo branch.
Esta é a desonestidade fundamental do tratamento de erros baseado em exceções. Cada throw é um caminho de fluxo de controle que o seu compiler não consegue ver, não consegue verificar e não consegue impor. Você acaba com código que passa perfeitamente na verificação de tipos e ainda assim quebra em produção porque alguém esqueceu um try/catch três camadas de chamada acima.
Retornos de erro explícitos corrigem isso. Eles tornam a falha um cidadão de primeira classe do seu sistema de tipos. A questão é se o custo ergonômico vale a honestidade.
O Que Corretude em Nível de Tipo Significa para Erros
Corretude em nível de tipo significa que seus tipos dizem a verdade sobre o que uma função pode produzir. Se uma função pode falhar, o tipo de retorno deve dizer isso. Se não pode, o tipo de retorno deve garantir sucesso.
Rust codifica isso com Result<T, E>:
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
A assinatura é completa. Quem chama sabe que há dois resultados. O compiler os força a lidar com ambos.
TypeScript, por padrão, não. Uma função que lança exceções parece idêntica a uma que não lança:
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
O tipo de retorno diz number. Deveria dizer number | Error, ou mais precisamente, Result<number, Error>. Mas não diz, então quem chama não tem nenhum sinal de que precisa se defender contra a falha.
Como Erros Lançados Se Escondem no Seu Grafo de Chamadas
O dano se acumula à medida que sua pilha de chamadas se aprofunda. Um erro lançado em uma função folha força cada camada intermediária a ou capturá-lo ou propagá-lo implicitamente. Nenhum desses caminhos de propagação aparece em nenhuma assinatura de tipo.
Considere um carregador de config que chama um parser que chama um validador. Se o validador lançar, o parser e o carregador também se tornam passíveis de lançar. Mas seus tipos não mudam. Você não pode olhar para loadConfig() e saber que pode falhar. Você tem que ler cada linha de cada dependência, ou esperar por uma falha em runtime.
Isso é exatamente o oposto de como os tipos deveriam funcionar. Tipos existem para que você não precise ler a implementação para entender o contrato.
Retornos de erro explícitos invertem isso. O caminho de falha é visível em cada camada:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
Agora a assinatura de loadConfig conta toda a história. Você sabe que pode falhar antes de ler uma única linha do corpo.
A Verdadeira Troca É Ergonomia, Não Expressividade
A abordagem honesta é mais verbosa. Ninguém gosta de escrever { ok: false, error: ... } em vez de throw new Error(...). O ruído visual se acumula, especialmente quando você está encadeando múltiplas operações falíveis.
É por isso que a maioria das linguagens com exceções verificadas as abandonou. As exceções verificadas do Java tentaram impor tratamento explícito em nível de tipo, mas a sintaxe era punitiva. O operador ? do Rust e os combinadores de Result como map e and_then recuperam grande parte dessa ergonomia. TypeScript não tem um operador ?, mas você pode chegar perto com funções auxiliares:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
Ainda é mais desajeitado que Rust. Se você está escrevendo TypeScript, bibliotecas como neverthrow fornecem um tipo Result com métodos encadeáveis que parecem quase nativos. A sobrecarga é real, mas é menor do que parece uma vez que você tem auxiliares em vigor.
Há outro custo. Stack traces de erros lançados são ricas e automáticas. Erros retornados são apenas valores. Se você quer uma stack trace, tem que construí-la você mesmo. Para debugging, isso importa. Para tratamento de erro previsível em lógica de negócio, geralmente não importa.
Quando Lançar Ainda Faz Sentido
Não estamos defendendo zero exceções. Algumas falhas são verdadeiramente excepcionais e não deveriam fazer parte do seu modelo normal de erro. Um arquivo ausente durante o carregamento de config é uma falha esperada. Um erro de falta de memória não é.
A regra prática: se uma falha é parte do seu domínio, retorne-a. Se é um bug de programação ou uma falha catastrófica do sistema, lance-a. JSON.parse lançar em input malformado é irritante porque JSON inválido é comum. Array.prototype.map lançar em um array nulo está bem porque chamar .map em null é um bug.
Na prática, isso significa que seus limites de I/O, parsers, validadores e regras de negócio devem retornar erros. Suas invariantes, asserções e condições verdadeiramente catastróficas ainda podem lançar.
Comece Pelos Limites da Sua API
Você não precisa refatorar toda a sua base de código. Os lugares de maior valor para adotar retornos de erro explícitos são suas APIs públicas e suas camadas de I/O. Esses são os limites onde quem chama precisa saber o que pode dar errado.
Escolha um module. Mude seus tipos de retorno para Result<T, E>. Atualize quem o chama. Veja como se sente. O padrão é viciante de uma forma específica: uma vez que você para de caçar através de stack traces por blocos try/catch faltando, você percebe quanta sobrecarga mental exceções lançadas estavam lhe custando.
Corretude em nível de tipo para erros não é sobre escrever código perfeito. É sobre escrever código que mente menos.