O uso de try/catch é o padrão mais comum para lidar com erros no Javascript.
Entretanto, essa abordagem possui algumas desvantagens. Não existe uma forma de verificar
se uma função possivelmente faz um throw. Também não é possível verificar
quais os erros que o catch vai receber. Isso tudo favorece um código que geralmente
inclui grandes blocos de try/catch, onde múltiplos erros são tratados de forma igual, e muitas informações são perdidas.
Além disso, o uso de throw prejudica o entendimento do código porque muda o fluxo do código. Nesse caso, para entender qual código vai ser executado depois do erro ser lançado, é necessário navegar por todo o código em busca do catch mais próximo, o que pode ser difícil e confuso.
async function getUser(id: string) {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
// Error 1: HTTP failure (404, 500, ...)
throw new Error(`Request failed: ${response.status}`);
}
// Error 2: response body is not valid JSON
const data = await response.json();
return data;
} catch (error) {
// Error 3: network error (DNS failed, offline, ...)
// `error` mixes all scenarios above.
// It is hard to react to each case without extra checks.
console.error("Failed to fetch user", error);
}
}
Por que o erro do catch é sempre unknown?
No JavaScript, o throw permite lançar qualquer tipo de valor, não apenas Error. É perfeitamente válido escrever throw "deu ruim", throw 404, ou até mesmo throw null.
Devido a essa flexibilidade, o TypeScript adota uma postura segura e tipa a variável de erro no bloco catch como unknown. Isso força o desenvolvedor a verificar o tipo do erro (usando typeof ou instanceof) antes de tentar acessar propriedades como .message ou .stack, garantindo que o valor existe e é do tipo esperado.
Uma alternativa possível é o padrão de utilizar Erro como valor. Essa abordagem é utilizada em algumas linguagens como Go, Rust e recentemente Kotlin. A ideia é que as funções retornem os erros como valores, deixando claro para o consumidor daquela função quais são os possíveis erros.
Esse padrão pode ser simulado no typescript com o tipo Result:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
O tipo Result representa a resposta de uma função sobre dois genéricos, o T, que representa
os dados retornados, e E que representa um erro. Além disso, possui uma propriedade discriminatória ok, que
indica de que se trata de um erro ou de um sucesso.
// Possible errors
type NetworkError = { _tag: "NetworkError"; message: string };
type NotFoundError = { _tag: "NotFoundError"; userId: string };
type ParseError = { _tag: "ParseError"; message: string };
type FetchUserError = NetworkError | NotFoundError | ParseError;
async function fetchUser(id: string): Promise<Result<User, FetchUserError>> {
try {
const response = await fetch(`/api/users/${id}`);
if (response.status === 404) {
return { ok: false, error: { _tag: "NotFoundError", userId: id } };
}
if (!response.ok) {
return {
ok: false,
error: {
_tag: "NetworkError",
message: `HTTP ${response.status}`,
},
};
}
const data = await response.json();
return { ok: true, value: data };
} catch {
return {
ok: false,
error: {
_tag: "ParseError",
message: "Failed to parse response",
},
};
}
}
const userResult = await fetchUser("123");
if (userResult.ok) {
console.log("User found:", userResult.value);
} else {
switch (userResult.error._tag) {
case "NotFoundError":
console.error(`User ${userResult.error.userId} not found`);
break;
case "NetworkError":
console.error(`Network error: ${userResult.error.message}`);
break;
case "ParseError":
console.error(`Parse error: ${userResult.error.message}`);
break;
}
}
O objetivo não é eliminar completamente o uso do throw/catch,
mas sim reservá-lo para situações de falha extrema, situações
em que a execução deve ser parada imediatamente.
Por exemplo, quando uma variável de ambiente obrigatória não está presente na inicialização,
ou quando a conexão com o banco de dados falha irremediavelmente durante o boot.
Assim, temos uma separação entre erros esperados (usuário não encontrado, saldo insuficiente, falha de validação) e erros não esperados (estouro de memória, erros de sintaxe SQL). Além disso, podemos adicionar metadados aos erros esperados, que facilitam debugação e observabilidade do sistema.
Embora seja possível definir seu próprio tipo Result e funções auxiliares manualmente, para aplicações reais é recomendado utilizar uma solução com mais recursos. A partir daqui, utilizaremos a biblioteca typescript-result.
A seguir, discutiremos três padrões distintos para tratar melhor os erros nas suas aplicações.
Criando um Result
Antes de poder consumir um Result, nós precisamos adaptar nossas funções para gerar um Result.
O typescript-result expõe algumas APIs para isso.
Result.ok e Result.error geram, respectivamente, um resultado de uma operação bem sucedida e um resultado de um erro.
import { Result } from "typescript-result";
type DiscountError =
| { _tag: "NegativeAmount"; amount: number }
| { _tag: "ExceedsMaximum"; amount: number; max: number };
function calculateDiscountPercentage(
amount: number,
): Result<number, DiscountError> {
if (amount <= 0) {
return Result.error({ _tag: "NegativeAmount", amount });
}
if (amount > 100) {
return Result.error({ _tag: "ExceedsMaximum", amount, max: 100 });
}
const finalValue = amount * 0.9;
return Result.ok(finalValue);
}
const result = calculateDiscountPercentage(30);
Você pode explicitamente definir o retorno como Result<number, DomainError>, por exemplo.
Apesar do TypeScript inferir os tipos automaticamente, definir o retorno explicitamente é considerado uma boa prática se você está criando uma função pública (biblioteca ou API), pois garante que o contrato da função seja respeitado e evita que tipos internos "vazem" acidentalmente.
Para facilitar a geração de Results a partir de funções que podem fazer um throw,
você pode utilizar o try ou wrap.
import { Result } from "typescript-result";
function divide(a: number, b: number) {
if (b === 0) throw new Error("Cannot divide by zero");
return a / b;
}
// Executes immediately and captures exceptions
const immediateResult = Result.try(() => divide(10, 0));
// Optional error mapping
const taggedResult = Result.try(
() => divide(10, 0),
(error) => ({ _tag: "MathError", message: String(error) }),
);
// Returns a new function that always returns Result
const safeDivide = Result.wrap(divide);
const result = safeDivide(10, 0);
Por que os erros possuem a propriedade \_tag?
Typescript possui verificação estrutural, o que significa que, para fins de verificação, os seguintes erros são os mesmos:
class ValidationError {
constructor(public message: string) {}
}
class DatabaseError {
constructor(public message: string) {}
}
Em alguns casos, o Typescript pode "colapsar" os tipos em um só, quando na verdade eles possuem significados semânticos diferentes.
A literal _tag facilita a distinção dos erros em runtime,
evitando o uso de instanceof. Em aplicações modernas (grandes monorepos, micro-frontends, etc), o instanceof pode falhar. Isso acontece porque a classe instanciada pode vir de um contexto ou bundle diferente da classe que você está usando para a comparação, mesmo que elas tenham o mesmo nome e código. Uma string literal (_tag) é imune a esse problema.
Você pode ler mais sobre a verificação estrutural do typescript no meu artigo: Entendendo Structural Typing no TypeScript.
Essas funções utilitárias são fundamentais quando precisamos interagir com bibliotecas externas ou código legado que não utilizam o padrão Result. Elas funcionam como uma barreira de proteção nas bordas da sua aplicação, encapsulando exceções imprevisíveis e trazendo-as para dentro do fluxo controlado e tipado do Result.
Até agora, vimos exemplos que retornam Promise<Result<T, E>>. Entretanto, a biblioteca typescript-result fornece um tipo especializado chamado AsyncResult<T, E>, que é essencialmente um alias para Promise<Result<T, E>>.
async function fetchUser(id: string): AsyncResult<User, FetchUserError> {
// ...
}
Verificação Manual de Erros
Agora, quando utilizamos uma função que retorna um erro, precisamos verificar se não temos um erro. Podemos tomar uma decisão com o que fazer com aquele erro, ou repassar ele para cima:
type ValidationError = { _tag: "ValidationError"; field: string };
type DatabaseError = { _tag: "DatabaseError"; message: string };
type NotFoundError = { _tag: "NotFoundError"; id: string };
async function processOrder(
orderId: string,
): AsyncResult<Order, ValidationError | DatabaseError | NotFoundError> {
const orderResult = await fetchOrder(orderId);
if (!orderResult.ok) return orderResult;
const order = orderResult.value;
const validationResult = validateOrder(order);
if (!validationResult.ok) {
if (validationResult.error._tag === "ValidationError") {
console.error(`Validation failed: ${validationResult.error.field}`);
}
return validationResult;
}
const saveResult = await saveToDatabase(order);
if (!saveResult.ok) return saveResult;
return Result.ok(saveResult.value);
}
Esse padrão é muito semelhante ao padrão utilizado em Go,
onde funções, por convenção, retornam sempre um possível resultado
e um possível erro. Em Go, em vez de usar throw/catch, as funções retornam
múltiplos valores: o resultado esperado e um erro (se houver). É como se JavaScript tivesse:
[resultado, erro] = await minhaFuncao() em cada chamada.
func processOrder(orderId string) (*Order, error) {
// In Go, the function returns two values: order and err
// If err is different to nil, it means there was an error
order, err := fetchOrder(orderId)
if err != nil {
return nil, err
}
// Validate the order
err = validateOrder(order)
if err != nil {
log.Printf("Validation failed: %v", err)
return nil, err
}
// Save to database
savedOrder, err := saveToDatabase(order)
if err != nil {
return nil, err
}
return savedOrder, nil
}
Uma das vantagens dessa abordagem é que ela é extremamente simples de entender, mesmo se alguém não estiver familiarizado com essa biblioteca ou padrão. Ele deixa explícito os pontos de tomada de decisão.
Entretanto, um problema é a verbosidade. Por exemplo, um erro precisa se propagar (bubble up) até atingir a camada adequada que vai lidar com ele.
Encadeando operações (Chaining)
E se, em vez de verificar o conteúdo de um Result, a gente tivesse um método que aplicasse uma função no Result somente se ele fosse um sucesso, retornando o result imediatamente se ele na verdade for um erro?
Toda instância de Result possui alguns métodos que permitem trabalhar com o conteúdo de um Result,
sem a necessidade de verificar seu conteúdo.
O .map() aplica uma função somente se o resultado corresponder com um sucesso (que pode retornar um valor, ou até mesmo um outro Result), enquanto que o .mapError() aplica a função somente se o resultado for um erro. Note que essas funções retornam outro Result,
o que possibilita encadear multiplas chamadas.
type PaymentError =
| { _tag: "InsufficientFunds"; required: number; available: number }
| { _tag: "InvalidCard"; reason: string }
| { _tag: "NetworkError"; message: string }
| { _tag: "SessionExpired" };
async function processPayment(amount: number): Promise<string> {
const result = await attemptPayment(amount);
if (!result.ok) {
return result
.match()
.when(
"InsufficientFunds",
(err) =>
`Insufficient funds. Required: $${err.required}, available: $${err.available}`,
)
.when("InvalidCard", (err) => `Invalid card: ${err.reason}`)
.when("NetworkError", (err) => `Network error: ${err.message}`)
.when(
"SessionExpired",
() => `Your session expired. Please log in again.`,
)
.run();
}
return `Payment of $${amount} was processed successfully.`;
}
Além disso, você pode utilizar o .else() para lidar com todos os outros erros da mesma forma.
O .match() funciona somente em falhas, é necessário verificar o valor de .ok antes.
return result
.match()
.when("NotFound", (err) => `Resource "${err.resource}" not found.`)
.when("Unauthorized", () => `You are not allowed to access this resource.`)
.else((err) => `Unexpected error: ${err._tag}. Try again later.`)
.run();
Conclusão
Mesmo que você não utilize especificamente a biblioteca typescript-result ou o padrão Result,
eu acredito que é importante você estar ciente dessas formas de tratar erros, e parar para pensar
em como você está lidando com erros na sua aplicação.
Se você tem interesse, eu recomendo ler a documentação do typescript-result, que possui
outros métodos e informações de como melhor utilizar a biblioteca.
Além disso, você pode explorar outras bibliotecas neverthrow e fp-ts. A biblioteca Effect vai além,
gerando um framework e ecossistemas em volta do conceito de Effect, uma função que recebe um contexto e retorna
um Result.