Functinal Error Handling com Typescript
Ao desenvolver aplicativos da web, especialmente no lado do servidor (backend), um dos principais desafios é lidar com erros de forma eficaz. Esse desafio é ampliado quando você adota abordagens de arquitetura limpa e desenvolve sua aplicação de dentro para fora, mantendo-a desacoplada de frameworks e camadas externas. Nesse contexto, seu código é tipicamente escrito em JavaScript puro ou TypeScript (ou qualquer outra linguagem que você preferir).
Visão Geral
Neste conteúdo, exploraremos a complexa questão do tratamento de erros e as diferentes abordagens para representá-los. Como que você pode comunicar efetivamente que uma função ou caso de uso encontrou um erro em algum ponto do fluxo? Vamos analisar várias abordagens, suas vantagens e desvantagens.
Problemas comuns no Tratamento de Erros
Ao abordar o tratamento de erros, nos deparamos com várias possibilidades. Algumas delas incluem:
- Retornar "null": Retornar um valor "null" em caso de erro pode parecer vago e não fornece informações específicas sobre o erro ocorrido.
- Uso de "throw": O uso de "throw" difere de um simples "return". Enquanto "return" interrompe o fluxo e retorna um valor, "throw" lança uma exceção no fluxo, exigindo que o chamador da função envolva a chamada em um bloco "try-catch" para tratar a exceção. Caso contrário, a exceção pode ser capturada pelo tratamento global de erros do framework, levando a respostas inesperadas (como internal server error).
- Respostas Diferentes para erros: Diferentes tipos de erros podem exigir respostas HTTP distintas, como um código de status 401 (Unauthorized) para falta de permissão ou um código de status 400 (Bad Request) para dados ausentes. Portanto, é importante fornecer detalhes suficientes nos erros para que o tratamento seja apropriado.
- Padronização de erros: É fundamental padronizar a estrutura dos erros em todo o aplicativo, independentemente de sua origem ou tipo de arquivo. Isso permite que você dispare erros em várias partes da aplicação de maneira consistente.
Functional Error Handling
Hoje quero te apresentar a abordagem do "Functional Error Handling" e como você pode adaptar um padrão amplamente utilizado na programação funcional para o contexto da Programação Orientada a Objetos (POO). Isso é particularmente relevante quando você está trabalhando com conceitos como Domain-Driven Design (DDD) e Clean Architecture.
O "Functional Error Handling" é comumente implementado em linguagens funcionais usando tuplas, que podem ser traduzidas para JavaScript como objetos com duas propriedades: "success" e "result" ou "error" e "reason". Essas estruturas permitem identificar se uma operação foi bem-sucedida e, se não, fornecer informações sobre o motivo do erro.
Colocando em prática
Imagine um cenário hipótetico onde temos um caso de uso na nossa aplicação para deletar uma foto. O código é o seguinte:
import { PhotosRepository } from '../repositories/photos-repository'
interface DeletePhotoUseCaseRequest {
photoId: string
authorId: string
}
interface DeletePhotoUseCaseResponse {}
export class DeletePhotoUseCase {
constructor(private photosRepository : PhotosRepository) {}
async execute({
photoId,
authorId,
}: DeletePhotoUseCaseRequest): Promise<DeletePhotoUseCaseResponse> {
const photo = await this.photosRepository.findById(photoId)
if (!photo) {
throw new Error('Photo not found.')
}
if (authorId !== photo.authorId.toString()) {
throw new Error('Not allowed.')
}
await this.photosRepository.delete(photo)
return {}
}
}
Veja que nesse exemplo os erros estão sendo "tratados" através do "throw" do JavaScript. Mas podemos e vamos melhorar essas tratativas de erro utilizando o Functional Error Handling.
Para começar, crie um arquivo chamado either.ts
em seu projeto. Neste arquivo, implementaremos duas classes para representar os dois possíveis resultados: erro (Left) e sucesso (Right). Aqui está o código:
// Classe de Erro
export class Left {}
// Classe de Sucesso
export class Right {}
Adicionamos uma propriedade readonly com nome de "value" a cada classe para armazenar informações sobre o erro ou o resultado do sucesso, e também criaremos funções auxiliares para facilitar a criação dessas classes:
// Classe de Erro
export class Left<T> {
readonly value: T;
constructor(value: T) {
this.value = value;
}
}
// Classe de Sucesso
export class Right<T> {
readonly value: T;
constructor(value: T) {
this.value = value;
}
}
// Função para criar uma instância de Left
export const left = <T>(value: T): Left<T> => {
return new Left(value);
}
// Função para criar uma instância de Right
export const right = <T>(value: T): Right<T> => {
return new Right(value);
}
Essas funções simplificam a criação de instâncias de Left e Right em todo o código.
Tipagem aprofundada
Para adicionar uma tipagem mais robusta, modificamos as classes Left e Right para aceitar tipos genéricos e adicionamos métodos auxiliares "isRight" e "isLeft" para facilitar a identificação do resultado. Também introduzimos um tipo "Either" que pode representar qualquer instância de Left ou Right. Aqui está o código atualizado:
// Classe de Erro
export class Left<L, R> {
readonly value: L
constructor(value: L) {
this.value = value
}
isRight(): this is Right<L, R> {
return false
}
isLeft(): this is Left<L, R> {
return true
}
}
// Classe de Sucesso
export class Right<L, R> {
readonly value: R
constructor(value: R) {
this.value = value
}
isRight(): this is Right<L, R> {
return true
}
isLeft(): this is Left<L, R> {
return false
}
}
export type Either<L, R> = Left<L, R> | Right<L, R>
// Função para criar uma instância de Left
export const left = <L, R>(value: L): Either<L, R> => {
return new Left(value)
}
// Função para criar uma instância de Right
export const right = <L, R>(value: R): Either<L, R> => {
return new Right(value)
}
Essas modificações tornam o tratamento de erros mais seguro em termos de tipagem e permitem que você identifique facilmente se o resultado é um erro ou sucesso.
Sendo franco essa é uma parte um pouco mais avançada da tipagem do TypeScript, então não se preocupe em entender ou decorar tudo que está acontecendo. Eu mesmo quando preciso desse código cópio e colo kkkkk 🤫
Se me permite fazer uma sugestão, você pode acessar esse repositório no Github e fazer um fork desse código, assim você também terá uma cópia para quando precisar e os commits estarão estruturados em formato de passo a passo, então fica fácil entender como implementar no seu projeto, além de você ter uma referência para acompanhar esse conteúdo aqui.
Voltando ao que interessa!
Erros Genéricos
É uma prática sólida criar erros genéricos para sua aplicação. Eles são erros que podem ser reutilizados em várias partes do app. No diretório errors
dentro da pasta use-cases
, você pode armazenar esses erros. Aqui estão dois exemplos de erros genéricos que vamos usar:
- ResourceNotFoundError.ts:
export class ResourceNotFoundError extends Error {
constructor() {
super('Resource not found.');
}
}
- NotAllowedError.ts:
export class NotAllowedError extends Error {
constructor() {
super('Not allowed.');
}
}
Esses erros genéricos podem ser facilmente reutilizados em seus casos de uso, tornando o tratamento de erros consistente em toda a aplicação.
Refatorando o Caso de Uso
Vamos refatorar o caso de uso hipotético anteriror que exclui uma foto. Agora o caso de uso pode retornar um erro do tipo "ResourceNotFoundError" ou "NotAllowedError" em caso de falha ou um objeto vazio em caso de sucesso. Veja o código atualizado:
import { left, right, Either } from '@/core/either';
import { PhotosRepository } from '../repositories/photos-repository';
import { NotAllowedError } from './errors/not-allowed-error';
import { ResourceNotFoundError } from './errors/resource-not-found-error';
interface DeletePhotoUseCaseRequest {
photoId: string
authorId: string
}
// A resposta do caso de uso é do tipo "Either" que pode ser um erro ou sucesso
type DeletePhotoUseCaseResponse = Either<ResourceNotFoundError | NotAllowedError, {}>;
export class DeletePhotoUseCase {
constructor(private photosRepository: PhotosRepository) {}
async execute({
photoId,
authorId,
}: DeletePhotoUseCaseRequest): Promise<DeletePhotoUseCaseResponse> {
const photo = await this.photosRepository.findById(photoId)
if (!photo) {
return left(new ResourceNotFoundError())
}
if (authorId !== photo.authorId.toString()) {
return left(new NotAllowedError())
}
await this.photosRepository.delete(photo)
return right({})
}
}
Nesse exemplo, o caso de uso pode retornar um erro específico se algo der errado, tornando o tratamento de erros mais claro e consistente.
Lembrando que essa abordagem de Functional Error Handling, pode ser utilizada para tratar erros em diversas camadas da aplicação e não somente em casos de uso.
Conclusão
O tratamento de erros é uma parte crítica do desenvolvimento de aplicativos da web. A abordagem de "Functional Error Handling" apresentada neste conteúdo permite um tratamento eficaz de erros e contribui para a coesão e consistência do código. Ao criar erros genéricos e usar tipos seguros do TypeScript, você pode garantir um tratamento de erros robusto e claro em toda a sua aplicação.
Espero que este post tenha sido útil e encorajo você a compartilhar sua experiência e conhecimento com a comunidade aqui nos comentários para continuarmos aprendendo juntos! 🚀
Eu não sou um expert em programação, muito pelo contrário, tenho muita coisa para aprender ainda, embora meu conhecimento seja pouco, acredito que ele possa ajudar quem esteja começando agora, e espero que isso acrescente algo de bom para a comunidade.
Vou deixar o link desse repo aqui, caso alguém queira fazer uma cópia do código para referência. E se você puder deixe uma estrela no repositório, isso vai me ajudar bastante!
Link do repositório: https://github.com/eoSalinas/functional-error-handling-typescript