Tratamento de erros elegante com o JavaScript Monad

James Sinclair Blocked Unblock Seguir Seguindo 15 de dezembro de 2018

Vamos falar sobre como lidamos com erros. JavaScript nos fornece um recurso de idioma interno para lidar com exceções. Envolvemos código problemático em instruções try...catch . Isso nos permite escrever o "caminho feliz" na seção try e tratar de quaisquer exceções na seção catch. Isso não é uma coisa ruim. Isso nos permite focar na tarefa em mãos, sem precisar pensar em todos os possíveis erros que possam ocorrer. É definitivamente melhor do que colocar nosso código em infinitas declarações if.

Sem try...catch , fica tedioso verificar o resultado de cada chamada de função para valores inesperados. Exceções e try...catch blocos servem a um propósito, mas eles têm alguns problemas. E eles não são a única maneira de lidar com erros. Neste artigo, vamos dar uma olhada em usar o 'Eadad' como uma alternativa para try...catch .

Algumas coisas antes de continuarmos. Neste artigo, assumimos que você já conhece a composição de funções e o currying . Se você precisar de um minuto para fazer isso, tudo bem. E uma palavra de advertência, se você não se deparou com coisas como mônadas antes, elas podem parecer realmente … diferentes. Trabalhar com ferramentas como essas exige uma mudança de mentalidade.

Não se preocupe se você ficar confuso no começo. Todo mundo faz. Eu listei algumas outras referências no final que podem ajudar. Mas não desista. Este material é inebriante quando você entra nele.

Um problema de amostra

Antes de entrarmos no que está errado com as exceções, vamos falar sobre o porquê elas existem. Há uma razão para termos coisas como exceções e try…catch blocos. Eles não são todos ruins o tempo todo .

Para explorar o tópico, tentaremos resolver um problema de exemplo. Eu tentei fazer pelo menos semi-realista. Imagine que estamos escrevendo uma função para exibir uma lista de notificações. Já conseguimos (de alguma forma) recuperar os dados do servidor. Mas, por qualquer motivo, os engenheiros de back-end decidiram enviá-lo em formato CSV em vez de JSON. Os dados brutos podem ser algo como isto:

Agora, eventualmente, queremos renderizar esse código como HTML. Pode parecer algo assim:

Para manter o problema simples, por enquanto, nos concentraremos apenas no processamento de cada linha dos dados CSV. Começamos com algumas funções simples para processar a linha. O primeiro vamos usar para dividir os campos:

Agora, esta função é supersimplificada porque este é um tutorial sobre manipulação de erros, não análise de CSV. Se houver alguma vírgula em uma das mensagens, isso será terrivelmente errado. Por favor, nunca use código como este para analisar dados reais de CSV. Se você precisar analisar dados CSV, use uma biblioteca de análise de CSV bem testada .

Depois de dividir os dados, queremos criar um objeto, no qual os nomes dos campos correspondam aos cabeçalhos de CSV. Vamos supor que já analisamos a linha de cabeçalho de alguma forma. Observe que lançamos um erro se o comprimento da linha não corresponder à linha do cabeçalho (e _.zipObject é uma função da lodash ):

Depois disso, adicionaremos uma data legível ao objeto, para que possamos imprimi-lo em nosso modelo. É um pouco detalhado, já que o JavaScript não tem suporte a formatação de data embutida. Observe que ele gera um erro para uma data inválida:

E finalmente, pegamos nosso objeto e o passamos por uma função de template para pegar uma string HTML:

Se acabarmos com um erro, também seria bom ter uma maneira de imprimir isso também:

E assim que tivermos todos esses elementos, podemos juntá-los para criar nossa função que processará cada linha:

Então, temos nossa função de exemplo. E não é tão ruim, no que diz respeito ao código JavaScript. Mas vamos dar uma olhada mais de perto em como estamos gerenciando exceções aqui.

Exceções: As partes boas

Então, o que há de bom em try...catch ? A coisa a ser observada é, no exemplo acima, qualquer uma das etapas no bloco try pode gerar um erro. Em zipRow() e addDateStr() nós intencionalmente lançamos erros. E se um problema acontecer, então nós simplesmente pegamos o erro e mostramos qualquer mensagem que o erro tenha na página. Sem esse mecanismo, o código fica realmente feio. Aqui está o que pode parecer sem exceções. Em vez de lançar exceções, assumimos que nossas funções retornarão nulo:

Como você pode ver, acabamos com um monte de declarações if-clichê. O código é mais detalhado. E é difícil seguir a lógica principal. Além disso, não temos como cada etapa nos informar qual deve ser a mensagem de erro ou porque falharam. (A menos que, nós façamos algum truque com variáveis globais.) Então, temos que adivinhar e chamar explicitamente showError() se a função retornar null. Sem exceções, o código é mais confuso e mais difícil de ser seguido.

Mas olhe novamente para a versão com manipulação de exceção. Isso nos dá uma separação clara e agradável do "caminho feliz" e do código de manipulação de exceção. A parte try é o caminho feliz, e a parte catch é o caminho triste (por assim dizer). Todo o tratamento de exceção acontece em um ponto. E podemos deixar que as funções individuais nos digam por que falharam. Tudo somado, parece muito bom. Na verdade, acho que a maioria de nós consideraria o primeiro exemplo um código limpo. Por que precisamos de outra abordagem?

Problemas com o try… catch exception handling

A coisa boa sobre as exceções é que eles permitem que você ignore essas condições de erro traquinas. Mas, infelizmente, eles fazem esse trabalho muito bem. Você acabou de lançar uma exceção e seguir em frente. Podemos descobrir onde pegá-lo depois. E todos nós pretendemos colocar essa try…catch bloco no lugar. Realmente, nós fazemos. Mas nem sempre é óbvio para onde deveria ir. E é muito fácil esquecer um. E antes que você perceba, seu aplicativo falha.

Outra coisa a se pensar é que as exceções tornam nosso código impuro. Por que a pureza funcional é uma coisa boa é uma outra discussão. Mas vamos considerar um pequeno aspecto da pureza funcional: transparência referencial. Uma função de referência transparente sempre dará o mesmo resultado para uma determinada entrada. Mas não podemos dizer isso sobre funções que lançam exceções. A qualquer momento, eles podem lançar uma exceção em vez de retornar um valor. Isso torna mais complicado pensar sobre o que um código realmente está fazendo. Mas e se pudéssemos ter as duas coisas? E se pudéssemos criar uma maneira pura de lidar com erros?

Chegando com uma alternativa

Se formos escrever nosso próprio código de tratamento de erros puro, precisamos sempre retornar um valor. Então, como primeira tentativa, e se retornássemos um objeto Error na falha? Ou seja, onde quer que estivéssemos lançando um erro, apenas o devolveríamos . Isso pode parecer algo assim:

Esta é apenas uma pequena melhoria na versão sem exceções. Mas é melhor. Nós movemos a responsabilidade pelas mensagens de erro de volta para as funções individuais. Mas é isso aí. Ainda temos todas essas declarações if. Seria muito bom se houvesse alguma maneira de encapsular o padrão. Em outras palavras, se soubermos que temos um erro, não se preocupe em executar o restante do código.

Polimorfismo

Então, como fazemos isso? É um problema complicado. Mas é possível com a magia do polimorfismo . Se você não encontrou polimorfismo antes, não se preocupe. Tudo o que isso significa é "fornecer uma interface única para entidades de tipos diferentes". ¹ Em JavaScript, fazemos isso criando objetos que possuem métodos com o mesmo nome e assinatura. Mas nós lhes damos comportamentos diferentes. Um exemplo clássico disso é o log de aplicativos. Podemos querer enviar nossos logs para lugares diferentes, dependendo do ambiente em que estamos. Assim, definimos dois objetos logger:

Ambos os objetos definem uma função de log que espera um único parâmetro de string. Mas eles se comportam de maneira diferente. A beleza disso é que podemos escrever código que chama .log() , mas não importa qual objeto está usando. Pode ser um consoleLogger ou um ajaxLogger . Isso funciona de qualquer maneira. Por exemplo, o código abaixo funcionaria igualmente bem com qualquer objeto:

Outro exemplo é o método .toString() em todos os objetos JS. Podemos escrever um método .toString() em qualquer classe que fizermos. Então, talvez possamos criar duas classes que implementem o .toString() diferente. Nós vamos chamá-los de esquerda e direita (eu vou explicar por que em um momento):

Agora, vamos criar uma função que irá chamar .toString() nesses dois objetos:

Não exatamente alucinante, eu sei. Mas o ponto é que temos dois tipos diferentes de comportamento usando a mesma interface – isso é polimorfismo. Mas observe algo interessante. Quantas declarações se usaram? Zero. Nenhum. Criamos dois tipos diferentes de comportamento sem uma única declaração if à vista . Talvez pudéssemos usar algo assim para lidar com nossos erros …

Esquerda e direita

Voltando ao nosso problema, queremos definir um caminho feliz e um caminho triste para o nosso código. No caminho feliz, apenas continuamos a executar nosso código até que um erro aconteça ou terminemos. Se acabarmos no triste caminho, não nos preocupamos mais em tentar executar o código. Agora, podemos chamar de nossas duas classes 'feliz' e 'triste' para representar dois caminhos. Mas vamos seguir as convenções de nomenclatura que outras linguagens de programação e bibliotecas usam. Dessa forma, se você continuar lendo, será menos confuso. Então, vamos chamar nosso triste caminho de "Esquerda" e nosso caminho feliz "certo" apenas para ficar com a convenção.

Vamos criar um método que pegue uma função e execute-a se estivermos no caminho feliz, mas ignore-a se estivermos no caminho triste:

Então poderíamos fazer algo assim:

Mapa

Estamos chegando perto de algo útil, mas ainda não chegamos lá. Nosso método .runFunctionOnlyOnHappyPath() retorna a propriedade _value . Tudo bem, mas torna as coisas inconvenientes se quisermos executar mais de uma função. Por quê? Porque já não sabemos se estamos no caminho feliz ou no caminho triste. Essa informação desaparece assim que tomamos o valor fora da esquerda ou da direita. Então, o que podemos fazer é retornar um Left ou Right com um novo _value dentro. E nós vamos encurtar o nome enquanto estamos nisso. O que estamos fazendo é mapear uma função do mundo dos valores simples para o mundo da esquerda e da direita. Então nós chamamos o método .map() :

Com isso, podemos usar Esquerda ou Direita com uma sintaxe de estilo fluente :

Nós criamos efetivamente duas faixas. Podemos colocar um pedaço de dados no caminho certo chamando new Right() e colocar um pedaço de dados na faixa esquerda chamando new new Left() .

Se mapearmos o caminho certo, seguiremos o caminho feliz e processaremos os dados. Se acabarmos no caminho da esquerda, nada acontece. Nós apenas continuamos passando o valor para baixo da linha. Se disséssemos, colocamos um Erro nessa faixa da esquerda, então temos algo muito similar para try…catch .

À medida que avançamos, chega a ser um pouco doloroso escrever "uma esquerda ou uma direita" o tempo todo. Então, vamos nos referir ao combo Esquerda e Direita juntos como "qualquer um". É tanto uma esquerda ou direita.

Atalhos para fazer os dois objetos

Então, o próximo passo seria a reescrever os nossos exemplos de funções para que eles retornam um qualquer. Uma esquerda para um erro ou um direito para um valor. Mas, antes de fazermos isso, vamos tirar um pouco do tédio disso. Vamos escrever alguns pequenos atalhos. O primeiro é um método estático chamado .of() . Tudo o que faz é retornar uma nova esquerda ou direita. O código pode ser assim:

Para ser honesto, acho que até mesmo Left.of () e Right.of () são tediosos para escrever. Então eu costumo criar atalhos ainda mais curtos chamados left () e right ():

Com os que estão no lugar, podemos começar a reescrever nossas funções de aplicação:

As funções modificadas não são tão diferentes das antigas. Nós apenas envolvemos o valor de retorno na esquerda ou direita, dependendo se encontramos um erro.

Com isso feito, podemos começar a refazer nossa função principal que processa uma única linha. Vamos começar colocando a string de linha em um Either com right() e, em seguida, mapeie splitFields() para dividi-lo:

Isso funciona muito bem, mas temos problemas quando tentamos a mesma coisa com zipRow() :

Isso ocorre porque o zipRow() espera dois parâmetros. Mas as funções que passamos para .map() só recebem um único valor da propriedade ._value . Uma maneira de corrigir isso é criar uma versão curry do zipRow() . Pode parecer algo assim:

Esta pequena alteração torna mais fácil transformar o zipRow() para que funcione bem com o .map() :

Junte-se

Usar .map() para executar splitFields() é bom, já que splitFields() não retorna um. Mas quando chegamos a correr zipRow (), temos um problema. Chamar zipRow() retorna um dos dois. Então, se usarmos .map() , acabamos colocando um dentro de um deles. Se formos mais longe, ficaremos presos, a menos que corremos .map() dentro de .map() . Isso não vai funcionar tão bem. Precisamos de alguma maneira de juntar esses Eithers aninhados em um só. Então, vamos escrever um novo método chamado .join() :

Agora estamos livres para desfazer nossos valores:

Cadeia

Nós fizemos muito mais longe. Mas lembrar de chamar .join() toda vez é irritante. Esse padrão de chamar .map() e .join() juntos é tão comum que criaremos um método de atalho para ele. Nós vamos ligar. chain() porque nos permite encadear funções que retornam à esquerda ou à direita:

Voltando à nossa analogia da trilha ferroviária, o .chain() nos permite trocar de trilhos se nos .chain() com um erro. É mais fácil mostrar com um diagrama.

Com isso, nosso código é um pouco mais claro:

Fazendo algo com os valores

Estamos quase terminando de refazer nossa função processRow() . Mas o que acontece quando retornamos o valor? Eventualmente, queremos tomar uma ação diferente, dependendo se temos uma esquerda ou direita. Então, vamos escrever uma função que levará ações diferentes de acordo:

Nós enganamos e usamos os valores internos dos objetos Left ou Right. Mas vamos fingir que você não viu isso. Agora podemos terminar nossa função:

E se nos sentimos particularmente inteligentes, poderíamos escrevê-lo usando uma sintaxe fluente:

Ambas as versões são bem legais. Não try…catch à vista. E nenhuma declaração if em nossa função de nível superior. Se houver algum problema com uma linha específica, mostramos uma mensagem de erro no final. E note que em processRow() a única vez que mencionamos Left ou Right é no início quando chamamos right() . Para o resto, apenas usamos os métodos .map() e .chain() para aplicar a próxima função.

Ap e levante

Isso parece bom, mas há um cenário final que precisamos considerar. Continuando com o exemplo, vamos dar uma olhada em como podemos processar os dados CSV inteiros , em vez de apenas cada linha. Precisamos de uma função auxiliar ou três:

Então, temos uma função auxiliar que divide os dados CSV em linhas. E nós temos um De volta. Agora, podemos usar .map() e algumas funções de lodash para dividir a linha de cabeçalho das linhas de dados. Mas acabamos em uma situação interessante …

Temos nossos campos de cabeçalho e linhas de dados prontos para mapear com processRows() . Mas headerFields e dataRows estão ambos dentro de um dos dois. Precisamos de alguma maneira para converter processRows() para uma função que funciona com o Eithers. Como primeiro passo, vamos curry processRows:

Agora, com isso, podemos realizar uma experiência. Nós temos headerFields que é um Either enrolado em um array. O que aconteceria se pegássemos headerFields e chamássemos .map() com processRows() ?

Usando .map() aqui chama a função externa de processRows() , mas não a interna. Em outras palavras, processRows() retorna uma função. E porque é .map() , ainda recebemos um De volta. Então, acabamos com uma função dentro de um deles. Eu dei um pouco com o nome da variável. funcInEither é um dos dois. Ele contém uma função que usa uma matriz de strings e retorna uma matriz de strings diferentes. Precisamos de alguma maneira de pegar essa função e chamá-la com o valor dentro de dataRows. Para fazer isso, precisamos adicionar mais um método às classes Left e Right. Vamos chamá-lo .ap() porque o padrão nos diz . A maneira de lembrar é lembrar que ap é a abreviação de "aplicar". Isso nos ajuda a aplicar valores às funções.

O método para a esquerda não faz nada, como de costume. E para a classe Right, o nome da variável diz que esperamos que o outro Either contenha uma função:

Então, com isso, podemos terminar nossa função principal:

Agora, já mencionei isso antes , mas acho o .ap() um pouco confuso para trabalhar. ² Outra maneira de pensar sobre isso é dizer: “Eu tenho uma função que normalmente levaria dois valores simples. Eu quero transformá-lo em uma função que leva dois Eithers ”. Agora que temos .ap() , podemos escrever uma função que fará exatamente isso. Vamos chamar de liftA2() , mais uma vez porque é um nome padrão. É necessária uma função simples, esperando dois argumentos, e "eleva" a trabalhar com "Aplicativos". (Applicatives são coisas que têm uma .ap() método e um .of() método). Então, liftA2() é a abreviação de 'lift applicative, two parameters'.

Então, liftA2() pode ser algo como isto:

Então, nossa função de nível superior iria usá-lo assim:

Você pode ver a coisa toda em ação no CodePen .

Mesmo? É isso?

Por que isso é melhor do que apenas lançar exceções? Bem, vamos pensar porque gostamos de exceções em primeiro lugar. Se não tivéssemos exceções, teríamos que escrever muitas declarações if em todo o lugar. Nós estaríamos sempre escrevendo código ao longo das linhas de 'se a última coisa funcionasse continuasse, senão lidasse com o erro'. E nós teríamos que continuar lidando com esses erros durante todo o nosso código. Isso torna difícil acompanhar o que está acontecendo. Atirar exceções nos permite sair do fluxo do programa quando algo dá errado. Portanto, não precisamos escrever todas essas declarações if. Podemos nos concentrar no caminho feliz.

Mas há um problema. Exceções escondem um pouco demais. Quando você lança uma exceção, você faz com que o erro de outra função seja resolvido. Mas é muito fácil ignorar a exceção e deixá-la chegar até o topo do programa. A coisa boa sobre o Either é que ele permite que você pule para fora do fluxo principal do programa como faria com uma exceção. Mas é honesto sobre isso. Você recebe um direito ou um esquerdo. Você não pode fingir que esquerdas não são uma possibilidade, eventualmente, você tem que puxar o valor para fora com algo como uma chamada either() .

Agora eu sei que isso soa como uma dor. Mas dê uma olhada no código que escrevemos (não nas classes Either, as funções que as utilizam). Não há muito código de manipulação de exceção lá. Na verdade, não há quase nenhum, exceto a chamada either() no final de csvToMessages() e processRow() . E esse é o ponto. Com o Either, você recebe um tratamento de erro puro que não pode ser esquecido acidentalmente. Mas sem ele pisando em seu código e adicionando recuo em todos os lugares.

Isso não quer dizer que você nunca deveria usar o try…catch . Às vezes, essa é a ferramenta certa para o trabalho, e tudo bem. Mas não é a única ferramenta. Usando o Either nos dá algumas vantagens que try…catch não pode corresponder. Então, talvez dê uma chance a algum dia. Mesmo que seja complicado no começo, acho que você vai gostar. Se você der uma chance, por favor, não use a implementação deste tutorial. Experimente uma das bibliotecas bem estabelecidas, como Crocks , Sanctuary , Folktale ou Monet . Eles são melhor mantidos. E eu tenho papered algumas coisas por causa da simplicidade aqui. E se você der uma chance, me avise enviando um tweet .

Leitura adicional

Texto original em inglês.