Table of contents
Uma coisa muito comum quando estamos programando são os erros, eventualmente algum erro irá acontecer, e o JavaScript nos oferece uma gama de opções para lidarmos com esse tipo de situação, por isso hoje iremos explorar 4 formas diferentes de se lidar com erros utilizando os recursos que a linguagem nos oferece.
Códigos de erro
A abordagem mais clássica muito utilizada em programação procedural é retornar códigos de erro das suas funções caso as coisas dêem errado. Dessa forma, se tudo correr bem, elas vão retornar o valor esperado, enquanto se derem errado, vão retornar um valor representando o erro que aconteceu. Ex:
const ERRORS = {
INVALID_USERNAME: 1,
INVALID_EMAIL: 2,
INVALID_PASSWORD: 3,
USER_ALREADY_EXISTS: 4
}
async function createUser({ name, email, password }) {
if (name === '') return ERRORS.INVALID_USERNAME;
if (!emailIsValid(email)) return ERRORS.INVALID_EMAIL;
if (!passwordIsSecure(password)) return ERRORS.INVALID_PASSWORD;
if (!(await userExists(name, email))) return ERRORS.USER_ALREADY_EXISTS;
const user = await createUserInDatabase({ name, email, password });
return user;
}
const result = await createUser({
name: 'Foo',
email: "foo@example.com",
password: 'secret123'
});
if (result === ERROS.INVALID_USERNAME) {
console.log("Nome de usuário inválido");
}
A vantagem dessa abordagem é que podemos tornar diversas situações onde as coisas podem dar errado possíveis de serem validadas posterior a execução da função, assim se tivermos um if/switch para validar cada erro possível retornado pela função podemos garantir que esses casos foram tratados no código, e podemos pegar mais eles mais facilmente no futuro caso algo dê errado.
A desvantagem é que não é uma estratégia muito manutenível, pois precisariamos criar um código de erro para cada erro possível, e além disso exigir que vários arquivos sejam alterados (levando a um aumento da chance de erro humano) ao mesmo tempo, isso torna o tipo de retorno da função inconsistênte, e em linguagens onde não se tem union types (felizmente quando utilizamos TS esse não é o caso), fica bem complicado de se modelar as funções dessa forma. Essa é uma abordagem que confia muito no uso de documentação para garantir que tudo funcione bem, o que contribui em deixar as coisas desatualizadas ao longo do tempo, pois um desenvolvedor pode alterar um código de erro, e não atualizar a documentação relacionada a este erro. Além disso, no livro clean code, o Uncle Bob, discute que utilizar códigos de erro leva o código a ser menos limpo.
Programação defensiva
Como podemos perceber a abordagem de códigos de erro não é lá a melhor do mundo, por isso, a abordagem que eu poderia considerar como uma das mais populares atualmente se propõe a ser bem melhor do que isso.
Na programação defensiva nós tentamos definir todos os casos que podem dar errado, e tratar isso o mais cedo possível, utilizando estruturas condicionais, tratamento de execeções com try catch. E para ver a diferença, vamos refatorar o exemplo anterior com essa abordagem:
async function createUser({ name, email, password }) {
if (name === '') throw new Error("Invalid User Name");
if (!emailIsValid(email)) throw new Error("Invalid Email");
if (!passwordIsSecure(password)) throw new Error("Invalid Password");
if (!(await userExists(name, email))) throw new Error("User Already Exists");
const user = await createUserInDatabase({ name, email, password });
return user;
}
try {
const result = await createUser({
name: 'Foo',
email: "foo@example.com",
password: 'secret123'
});
} catch (error) {
console.log(error.message)
}
Aparentemente não mudou muito em relação ao exemplo passado, porém aqui temos uma vantagem que é por termos lançado erros no lugar de retornar códigos de erro, o tipo do retorno voltou a ser um só, pois o código vai parar de executar caso o erro seja lançado, dessa forma isso nos obriga a tratar esse erro pegando ele via um try catch ou vamos descobrir que algo deu errado pela falha na execução do código.
Ainda retemos a habilidade de verificar qual erro aconteceu, pois podemos utilizar herança e estender a classe Error e criar erros personalizados, e depois verificar de qual classe o erro pego é utilizando o instanceof, além disso, podemos fazer as funções de validação lançarem o erro e tornar elas mais reutilizáveis em outros contextos, pois se uma função lança um erro, e a função que usou esta última não trata o erro, o erro é lançado automaticamente para cima, até alguém tratar ele, caso ninguém trate, o código para de funcionar, e ele é mostrado pela plataforma (por exemplo no console). Vamos ver como ficaria o nosso exemplo utilizando esses novos insights:
class InvalidUserNameError extends Error {
constructor() {
super("Invalid User Name");
}
}
class InvalidEmailError extends Error {
constructor() {
super("Invalid Email");
}
}
class InvalidPasswordError extends Error {
constructor() {
super("Invalid Password");
}
}
class UserAlreadyExistsError extends Error {
constructor() {
super("User Already Exists");
}
}
async function createUser({ name, email, password }) {
// Lança o erro InvalidUserNameError
validateUserName(name);
// Lança o erro InvalidEmailError
validateEmail(email);
// Lança o erro InvalidPasswordError
validatePassword(password);
// Lança o erro UserAlreadyExistsError
await assertThatIsNewUser(name, email);
const user = await createUserInDatabase({ name, email, password });
return user;
}
try {
const result = await createUser({
name: 'Foo',
email: "foo@example.com",
password: 'secret123'
});
} catch (error) {
if (error instanceof InvalidUserNameError) {
console.log("Nome inválido")
}
console.log(error.message);
}
As vantagens nesse caso é que podemos realmente expressar os erros através do nosso código, e como o erro é uma classe, se ele precisar ser atualizado, o código vai ser atualizado ao mesmo tempo, o que reduz os problemas relacionados a documentação desatualizada e clareza do código. Essa abordagem tende a deixar o código mais limpo.
A desvantagem é que se você abusar muito do lançamento de erros, isso pode levar a um hadouken code se você for colocando um if dentro do outro ou um try catch dentro do outro, além de que se tiverem muitos erros sendo lançados isso pode afetar negativamente de forma significativa a performance da aplicação.
Tuplas
Agora vamos para uma abordagem mais incomum no ecossistema JS (embora seja comum no ecossistema python), e que funciona como uma forma de meio termo entre as duas abordagens anteriores, que é utilizar uma tupla (um array com posições e tipos fixos) para representar um erro na primeira posição, e o valor de fato retornado pela função na segunda posição. Vamos ao exemplo:
async function createUser({ name, email, password }) {
if (name === '') return ['Invalid User Name', null];
if (!emailIsValid(email)) return ['Invalid Email', null];
if (!passwordIsSecure(password)) return ['Invalid Password', null];
if (!(await userExists(name, email))) return ['User Already Exists', null];
const user = await createUserInDatabase({ name, email, password });
return [null, user];
}
const [error, user] = await createUser({
name: 'Foo',
email: "foo@example.com",
password: 'secret123'
});
if (error) {
console.log(error)
}
// use user...
Nessa abordagem quando existe um erro, ele é adicionado no primeiro item do array, e o segundo fica como null, caso contrário, o null vai na primeira casa, e o valor na segunda. Dessa forma ao executar a função podemos desestruturar o resultado e fazer um if para averiguar se o erro aconteceu ou não.
Nós podemos continuar verificando por um erro em específico usando um discriminated union no TS, ou criando outros valores que não sejam uma string (por exemplo um objeto literal com uma propriedade type e outra message), e validando eles, neste exemplo utilizamos uma string para simplificar as coisas.
O tipo de retorno também se mantém consistente apesar dele ser um array agora. E ela não tem tantos problemas de performance por conta de retornar um valor e tratar ele numa estrutura condicional ao invés de usar um try catch com lançamento de erros.
A desvantagem é que essa é uma abordagem que cria um valor de retorno mais complexo o que pode tornar a composição de funções desse tipo um pouco mais complicada, além de depender de convenções e dos envolvidos conhecerem o padrão para que ela seja efetiva, o que aumenta novamente a necessidade de uma melhor documentação do código.
Either Monad
E para fechar vamos falar da minha alternativa favorita, vinda direto da programação funcional: a Either Monad. Essa é uma abordagem que funciona como uma evolução da abordagem anterior onde criamos um valor mais complexo, no caso uma monada, que representa um valor que pode ou ser bem sucedido ou mal sucedido, e que tem operações (métodos) que nos ajudam a tratar cada caso, lembrando muito uma promise (os métodos then e catch), só que síncrono.
Como de praxe, vamos para o exemplo:
async function createUser({ name, email, password }) {
if (name === '') return Either.left('Invalid User Name');
if (!emailIsValid(email)) return Either.left('Invalid Email');
if (!passwordIsSecure(password)) return Either.left('Invalid Password');
if (!(await userExists(name, email))) return Either.left('User Already Exists');
const user = await createUserInDatabase({ name, email, password });
return Either.right(user);
}
const result = await createUser({
name: 'Foo',
email: "foo@example.com",
password: 'secret123'
});
result
.map(user => ({ ...user, created: true }))
.match({
left: (error) => console.log(error),
right: (user) => console.log(user)
})
Note que ela é bem semelhante a abordagem da tupla pois ela cria um valor especial para representar ou o sucesso ou a falha, só que ao invés de deixarmos ele puro, nós criamos algumas operações adicionais para facilitar a nossa vida, como o map que permite que nós façamos algumas computações em cima do valor no caso de sucesso (nada será feito em caso de falha), e depois de nós aplicarmos todas as computações necessárias, podemos utilizar o método match para fazer algo com o resultado no caso dele ser um erro (left) ou um sucesso (right).
Essa abordagem permite que nós nos concentremos em programar só pensando no caso de sucesso encadeando chamadas do método map, e só lá no final pensarmos em como tratar o erro pois temos que tratar ambos ao mesmo tempo. Ela é conhecida como Railway Oriented Programming e pelo escopo do artigo a explicação foi bem simplificada, mas caso tenha interesse nessa abordagem, recomendo esse artigo incrível sobre o assunto.
A desvantagem dela é que precisamos criar esse valor Either já que ele não existe na standart lib do JS, além de exigir mais conhecimento para se utilizar pela natureza do que é uma Monada, e ser mais complexa de se implementar e lidar ao longo da aplicação, apesar de ser uma abordagem mais legível, e segura.
Atualmente essa é uma das abordagens mais populares de se lidar com erros inclusive sendo adotada em linguagens como o Rust por exemplo. Sendo poderosa como lançamento de erros porém legível e flexível como um bom código declarativo.
Caso esteja curioso, podemos implementar a monada de Either (ou Result em algumas linguagens) de diversas formas, e aqui vai um exemplo:
const Right = (value) => ({
map: (fn) => Right(fn(value)),
flatMap: (fn) => fn(value),
fold: () => value,
match: ({ right, left }) => right(value)
});
const Left = (value) => ({
map: (fn) => Left(value),
flatMap: (fn) => Left(value),
fold: () => value,
match: ({ right, left }) => left(value)
});
const Either = {
right: (value) => Right(value),
left: (value) => Left(value)
}
Se gostar de TypeScript:
type EitherPattern<L, R, U> = {
left: (value: L) => U,
right: (value: R) => U
}
type Either<L, R> = {
map: <U>(fn: (value: R) => U) => Either<L, U>,
flatMap: <U>(fn: (value: R) => Either<L, U>) => Either<L, U>,
fold: () => R | L,
match: <U>(pattern: EitherPattern<L, R, U>) => U
}
const Right = <L, R>(value: R): Either<L, R> => ({
map: <U>(fn: (value: R) => U) => Right(fn(value)),
flatMap: <U>(fn: (value: R) => Either<L, U>) => fn(value),
fold: () => value,
match: <U>({ right, left }: EitherPattern<L, R, U>) => right(value)
});
const Left = <L, R>(value: L): Either<L, R> => ({
map: <U>(fn: (value: R) => U) => Left(value),
flatMap: <U>(fn: (value: R) => Either<L, U>) => Left(value),
fold: () => value,
match: <U>({ right, left }: EitherPattern<L, R, U>) => left(value)
});
const Either = {
right: <L, R>(value: R): Either<L, R> => Right(value),
left: <L, R>(value: L): Either<L, R> => Left(value)
}
Conclusão
Obrigado por ler até aqui e espero que este artigo tenha te ajudado a ganhar novas ideias sobre como lidar com erros no dia-a-dia. Entendo os leitores que tenham achado a última alternativa um pouco mais difícil de entender, e nesse caso recomendo que acompanhe a nossa série sobre programação funcional, lá abordamos monadas e muito mais. Até a próxima.