Código simples é diferente de código simplista: Elm vs JavaScript

Existem linguagens, frameworks e bibliotecas que se esforçam para permitir que seja possível realizar tarefas relativamente complexas escrevendo poucas linhas de código. JavaScript é um bom exemplo. Para fazer uma chamada http para uma página do meu site usando esta linguagem, basta escrever uma única linha:

await fetch("https://segunda.tech/sobre")

A maioria das pessoas provavelmente não considera este código é difícil ou complexo, mas podem existir cenários de erros escondidos que não são triviais de tratar. Para analisar isso, vou mostrar uma implementação de uma pequena página utilizando JavaScript puro e discutir os potencias problemas. Em seguida vou mostrar como implementar a mesma solução utilizando a linguagem de programação Elm e analisar os mesmos pontos.

Exercício: recuperando os nomes dos Pokémons

Para exemplificar o problema que quero discutir neste artigo, implementei em html e JavaScript puro (utilizando Ajax) o mínimo necessário para exibir uma lista com nomes de Pokémons. Para isso utilizei a API do site PokéAPI. O endpoint para recuperar a lista dos 5 primeiros Pokémons é bem simples: basta acionar a URL https://pokeapi.co/api/v2/pokemon?limit=5 e o retorno será um json contendo o resultado abaixo.

{
  "count": 1118,
  "next": "https://pokeapi.co/api/v2/pokemon?offset=5&limit=5",
  "previous": null,
  "results": [
    {
      "name": "bulbasaur",
      "url": "https://pokeapi.co/api/v2/pokemon/1/"
    },
    {
      "name": "ivysaur",
      "url": "https://pokeapi.co/api/v2/pokemon/2/"
    },
    {
      "name": "venusaur",
      "url": "https://pokeapi.co/api/v2/pokemon/3/"
    },
    {
      "name": "charmander",
      "url": "https://pokeapi.co/api/v2/pokemon/4/"
    },
    {
      "name": "charmeleon",
      "url": "https://pokeapi.co/api/v2/pokemon/5/"
    }
  ]
}

Neste exercício o objetivo é recuperar estes dados de forma assíncrona e listar na página html apenas o conteúdo do campo name (que está dentro de result).

Implementando uma solução utilizando html e JavaScript puro

Existem várias formas de se resolver este problema utilizando estas tecnologias. Abaixo apresento a minha implementação.

<!doctype html>

<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <title>Lista de Pokémons em HTML e JavaScript</title>
  <meta name="author" content="Marcio Frayze David">
</head>

<body>
  <p id="loading-message">
    Carregando lista de nomes dos Pokémons, aguarde...
  </p>

  <ul id="pokemon-names-list">
  </ul>

  <script>

    (async function() {

      await fetch("https://pokeapi.co/api/v2/pokemon?limit=5")
        .then(data => data.json())
        .then(dataJson => dataJson.results)
        .then(results => results.map(pokemon => pokemon.name))
        .then(names => addNamesToDOM(names))

      hideLoadingMessage()

    })();

    function addNamesToDOM(names) {
      let pokemonNamesListElement = document.getElementById('pokemon-names-list')
      names.forEach(name => addNameToDOM(pokemonNamesListElement, name))
    }

    function addNameToDOM(pokemonNamesListElement, name) {
      let newListElement = document.createElement('li')
      newListElement.innerHTML = name
      pokemonNamesListElement.append(newListElement)
    }

    function hideLoadingMessage() {
      document.getElementById('loading-message').style.visibility = 'hidden'
    }

  </script>

</body>
</html>

A ideia é que ao finalizar a chamada Ajax, a mensagem de carregamento deixe de aparecer e a lista contendo os nomes dos Pokémons seja carregada dentro da tag com o id pokemons-names-list. Publiquei esta página no editor on-line JSFiddle para que veja o comportamento esperado.

Sei que dificilmente alguém escreveria um código desta forma. Não usei nenhum framework ou biblioteca externa e fiz algumas coisas que muitos considerariam más práticas (como por exemplo colocar o código JavaScript direto no html). Mas mesmo que tivesse implementado esta solução com tecnologias populares como React, JSX e Axios, os potencias problemas que quero discutir aqui provavelmente ainda existiriam.

Analisando o código acima, as perguntas que gostaria que você tentasse responder são:

  • O que vai acontecer caso ocorra um timeout na chamada Ajax?
  • Se o servidor voltar um status http de falha, o que vai acontecer?
  • Se o servidor retornar um status http de sucesso mas o formato do conteúdo retornado for diferente do esperado, o que vai acontecer?

O código acima não responde nenhuma destas perguntas de forma clara. É fácil visualizar o "caminho feliz", mas qualquer situação inesperada não esta sendo tratada de forma explícita. E embora nunca devêssemos colocar um código em produção que não trate estes cenários, a linguagem JavaScript não nos obriga a lidar com eles. Caso alguém do seu time esqueça de fazer o tratamento adequado para um desses potenciais problemas, o resultado será um erro em tempo de execução.

Se o seu time tiver azar, talvez estes cenários só apareçam quando o código já estiver em produção. E quando isso inevitavelmente acontecer, é bem provável que coloquem a culpa na pessoa desenvolvedora que implementou aquela parte do sistema.

Mas se sabemos que este tipo de situação precisa obrigatoriamente ser tratada, por que as linguagens, frameworks e bibliotecas permitem que este tipo de código seja escrito?

O que é uma solução simples?

Existe uma diferença grande entre uma solução ser simples e ser simplista. Esta solução que escrevi em JavaScript não é simples, mas simplista, já que ela ignora aspectos fundamentais do problema em questão.

Já linguagens como Elm, por sua vez, tendem a nos obrigar a pensar e implementar a solução para todos os potenciais cenários de problemas. O código final provavelmente será maior, mas trará consigo a garantia de que não vamos ter erros em tempo de execução, já que o compilador verifica e impõe que a pessoa desenvolvedora trate todos os caminhos possíveis, não deixando espaço para falhas previsíveis.

Claro que isso não significa que webapps criadas nesta linguagem estão isentas de todo e qualquer tipo de erro. Podem ocorrer problemas na lógica de negócio e a aplicação ter um comportamento inesperado, ou aspectos visuais do sistema podem não estar como gostaríamos. Mas aqueles erros previsíveis, que podem ser encontrados por um compilador, vão deixar de existir. Um bom exemplo é o famoso Undefined is not a function do JavaScript. Já em Elm, é impossível escrever um código que resulte em qualquer erro de runtime.

Outra vantagem desta abordagem é que temos um código realmente auto-documentado. Deve ficar muito claro, por exemplo, qual o formato do retorno esperado, quais campos são obrigatórios e quais são opcionais, etc.

Implementando a mesma solução em Elm

Agora vamos analisar uma solução escrita em Elm para este mesmo problema. Se você não conhece essa linguagem (ou alguma outra similar, como Haskell ou PureScript), provavelmente vai achar a sua sintaxe um pouco estranha. Mas não se preocupe, você não precisa entender totalmente este código para compreender a proposta deste artigo.

Primeiro precisamos de um arquivo html simples, que irá hospedar nossa página. Esta abordagem é bastante similar ao que é feito quando utilizamos ferramentas como React ou Vue.

<!doctype html>

<html lang="pt-BR">
<head>
  <meta charset="utf-8">
  <title>Lista de Pokémons em HTML e Elm</title>
  <meta name="author" content="Marcio Frayze David">
</head>

<body>
  <main></main>
  <script>
    Elm.Main.init({ node: document.querySelector('main') })
  </script>
</body>
</html>

Desta vez nosso html quase não tem lógica. Ele apenas irá carregar a aplicação escrita em Elm (previamente compilada) e colocar seu conteúdo dentro da tag main.

Agora sim a parte interessante: o código escrito em Elm. Vou primeiro listar o código por completo e depois destacar e comentar algumas partes mais relevantes para o tema deste artigo.

module Main exposing (..)

import Browser
import Html exposing (..)
import Http
import Json.Decode exposing (Decoder)


-- MAIN


main =
  Browser.element
    { init = init
    , update = update
    , subscriptions = subscriptions
    , view = view
    }


-- MODEL


type alias PokemonInfo = { name : String }

type Model
  = Failure
  | Loading
  | Success (List PokemonInfo)


init : () -> (Model, Cmd Msg)
init _ =
  (Loading, fetchPokemonNames)


-- UPDATE


type Msg
  = FetchedPokemonNames (Result Http.Error (List PokemonInfo))


update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of

    FetchedPokemonNames result ->
      case result of
        Ok pokemonsInfo ->
          (Success pokemonsInfo, Cmd.none)

        Err _ ->
          (Failure, Cmd.none)


-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.none


-- VIEW


view : Model -> Html Msg
view model =
  case model of
    Failure ->
        text "Por alguma razão, não foi possível carregar a lista com nome dos Pokémons. 😧"

    Loading ->
      text "Carregando lista de nomes dos Pokémons, aguarde..."

    Success pokemonsInfo ->
      ul []
        (List.map viewPokemonInfo pokemonsInfo) 


viewPokemonInfo : PokemonInfo -> Html Msg
viewPokemonInfo pokemonInfo =
  li [] [ text pokemonInfo.name ]


-- HTTP


fetchPokemonNames : Cmd Msg
fetchPokemonNames =
  Http.get
    { url = "https://pokeapi.co/api/v2/pokemon?limit=5"
    , expect = Http.expectJson FetchedPokemonNames decoder
    }


pokemonInfoDecoder : Decoder PokemonInfo
pokemonInfoDecoder =
  Json.Decode.map PokemonInfo
    (Json.Decode.field "name" Json.Decode.string)

decoder : Decoder (List PokemonInfo)    
decoder =
  Json.Decode.field "results" (Json.Decode.list pokemonInfoDecoder)

Publiquei esta página no editor online Ellie para que possa ver este webapp em funcionamento. Recomendo que tente alterar o código e veja o que acontece. É uma ótima forma de começar a experimentar a linguagem Elm.

Analisando a implementação em Elm

Não irei neste artigo explicar todo este código e a arquitetura por trás da linguagem Elm. Mas queria destacar algumas partes importantes para o contexto da discussão deste artigo, começando pela definição dos nossos types.

Definição de tipos

type alias PokemonInfo = { name : String }

type Model
  = Loading
  | Failure
  | Success (List PokemonInfo)

No código acima primeiro é definido um type alias, tornando mais claro para pessoa que está lendo o código o que é um PokemonInfo (neste caso, uma estrutura com um campo chamado name do tipo String). Isso também facilitará a vida do nosso compilador, permitindo que faça o tratamento de erro adequado quando necessário e, durante a fase de compilação, consiga emitir mensagens de erros mais informativas.

Em seguida, definimos um type chamado Model que será utilizado para representar o estado atual da nossa aplicação. Neste exemplo, nossa webapp pode estar em um (e apenas um) dos 3 possíveis estados:

  • Loading: estado inicial da aplicação, indicando que a requisição http ainda está sendo processada.
  • Failure: representa um estado de falha, indicando que ocorreu algum problema ao realizar a chamada http ao servidor (podendo ser timeout, falha no parsing da mensagem de retorno, etc).
  • Success: indica que a requisição foi realizada e seu retorno convertido com sucesso.

Dos três estados definidos, apenas o Success possui uma informação extra associada a ele: uma lista contendo elementos do tipo PokemonInfo. Note que isso não deixa espaço para ambiguidades. Se tivermos um estado de sucesso, obrigatoriamente temos uma lista de PokemonInfo definida e com uma estrutura válida. E o contrário também: em caso de falha, a lista com os nomes dos Pokémons não estará definida.

A construção da página

Elm foi uma das pioneiras em utilizar o conceito de DOM virtual e programação declarativa no desenvolvimento de webapp.

Na arquitetura do Elm, existe uma separação bastante clara entre o estado da nossa aplicação e o que deve ser exibido na tela. É responsabilidade da função view montar, a partir do estado atual da nossa aplicação, uma representação da nossa DOM virtual. E toda vez que o estado for alterado (quando, por exemplo, terminar de carregar os dados com nomes dos Pokémons) esta função será reavaliada e uma nova DOM virtual criada.

Em nosso exemplo, isso ocorre no seguinte trecho de código:

view : Model -> Html Msg
view model =
  case model of
    Failure ->
        text "Por alguma razão, não foi possível carregar a lista com nome dos Pokémons. 😧"

    Loading ->
      text "Carregando lista de nomes dos Pokémons, aguarde..."

    Success pokemonsInfo ->
      ul []
        (List.map viewPokemonInfo pokemonsInfo) 


viewPokemonInfo : PokemonInfo -> Html Msg
viewPokemonInfo pokemonInfo =
  li [] [ text pokemonInfo.name ]

Temos aqui a declaração de 2 funções: a view e uma função auxiliar chamada viewPokemonInfo.

Uma vantagem de utilizar types para representação do estado da nossa aplicação é que sempre que um trecho de código for utilizar este type, o compilador irá obrigar a pessoa desenvolvedora a tratar todos os possíveis estados. Neste caso: Loading, Failure e Success. Se você remover o tratamento do Loading da função view do nosso exemplo, receberá uma mensagem de erro similar a esta ao tentar compilar a aplicação:

Line 70, Column 3
This `case` does not have branches for all possibilities:

70|>  case model of
71|>    Failure ->
72|>        text "Por alguma razão, não foi possível carregar a lista com nome dos Pokémons. 😧"
73|>
74|>    Success pokemonsInfo ->
75|>      ul []
76|>        (List.map viewPokemonInfo pokemonsInfo) 

Missing possibilities include:

    Loading

I would have to crash if I saw one of those. Add branches for them!

Hint: If you want to write the code for each branch later, use `Debug.todo` as a
placeholder. Read <https://elm-lang.org/0.19.1/missing-patterns> for more
guidance on this workflow.

Isso traz mais segurança para a pessoa desenvolvedora refatorar o código e incluir ou remover estados da aplicação, tendo a certeza que não vai deixar de tratar algum caso obscuro.

Fazendo uma chamada http

O trecho de código abaixo é responsável por fazer a chamada http de forma assíncrona e realizar o parse do retorno, transformando-o em uma lista de PokemonInfo.

fetchPokemonNames : Cmd Msg
fetchPokemonNames =
  Http.get
    { url = "https://pokeapi.co/api/v2/pokemon?limit=5"
    , expect = Http.expectJson FetchedPokemonNames decoder
    }


pokemonInfoDecoder : Decoder PokemonInfo
pokemonInfoDecoder =
  Json.Decode.map PokemonInfo
    (Json.Decode.field "name" Json.Decode.string)


decoder : Decoder (List PokemonInfo)    
decoder =
  Json.Decode.field "results" (Json.Decode.list pokemonInfoDecoder)

Impossível negar que este código é maior do que uma chamada a uma função fetch. Mas note que este código, além de fazer a chamada de forma assíncrona, também valida e transforma o retorno em uma List PokemonInfo, eliminando a necessidade de qualquer validação por nossa parte.

No final da execução da chamada será emitida uma mensagem FetchedPokemonNames junto com o resultado da operação: ou uma lista com nomes dos Pokémons já decodificados ou então um resultado representando que ocorreu um erro.

Será responsabilidade da função update receber esta mensagem e criar um novo estado para a aplicação.

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of

    FetchedPokemonNames result ->
      case result of
        Ok pokemonsInfo ->
          (Success pokemonsInfo, Cmd.none)

        Err _ ->
          (Failure, Cmd.none)

Mais uma vez, somos obrigados a tratar todos os possíveis cenários. Neste exemplo, são dois:

  • caso o result seja do tipo Ok, significa que nossa requisição foi processada com sucesso. É retornado então um novo estado para nossa aplicação, alterando para Success, junto com a lista contendo os nomes dos Pokémons.
  • caso o result seja do tipo Err, então sabemos que ocorreu algum problema durante a requisição ou ao realizar o parsing do json. Um novo estado da aplicação é retornado, alterando-o para Failure.

Sempre que o retorno da função update for diferente do estado anterior, automaticamente a função view será acionada novamente, então uma nova DOM virtual será criada e eventuais alterações serão aplicadas na tela. Para entender melhor este processo, você pode ler sobre a The Elm Architecture nesta página.

Conclusões

Embora tenha focado exclusivamente nas requisições http e no JavaScript, os mesmos conceitos são aplicados em muitos outros cenários, bibliotecas, frameworks e linguagens.

Minha intenção não é desmotivar o uso de JavaScript. Elm é uma linguagem maravilhosa, mas até hoje ainda uso JavaScript e TypeScript em alguns webapps e este não é o ponto focal do problema. O que eu gostaria é que quando você for consumir uma função de sua linguagem preferida (seja uma função nativa, seja de uma bibliotecas de terceiros), que você sempre reflita e responda para si mesma: existe algum cenário que este código está ignorando? Ou, em outras palavras, esta é uma solução simples ou simplista?

E o mais importante: ao escrever uma nova função, utilize uma interface de comunicação que incentive a pessoa que for consumi-la a seguir as boas práticas. Mesmo que ela esteja seguindo o caminho do mínimo esforço, deve ser capaz de se precaver de todos os cenários possíveis. Ou, em outras palavras, sempre siga o Princípio de menor espanto.

Gostou deste texto? Conheça meus outros artigos, podcasts e vídeos acessando: https://segunda.tech.

20