Tipos e imutabilidade em JavaScript: por que você deveria se preocupar?
Recentemente, tive o prazer de apresentar Tipos e Imutabilidade no maior evento de JavaScript do mundo, confira o vídeo. Por acreditar que o assunto é muito pertinente, resolvi também escrever um texto a respeito. Mais do que uma simples transcrição do conteúdo, este texto trará uma ótica diferente. Palestra e artigo são complementares.
Tipos
De maneira simplória, a partir do agrupamento de alguns valores e reconhecimento de operações em comum, é possível definir um tipo. Devido aos nossos conhecimentos de matemática, facilmente identificamos que a sequência de valores 11
, 3
, 67
e 89
pode ser somada, ao mesmo tempo em que sabemos que a sequência de JS
, is
e cool
não pode ser multiplicada, porém naturalmente unida.
Em uma linguagem de programação, é com base nos tipos que se determina o quanto de memória é necessário para armazenar um valor. São também os tipos que ditam quais operações e métodos podem ser aplicados a determinado valor.
O JavaScript possui seis tipos primitivos: string
, number
, undefined
, null
, boolean
e symbol
e um tipo composto ou object
. Curiosamente, tipos primitivos não possuem propriedades e, graças ao object String
, podemos consultar o tamanho de uma palavra ou frase. O código "a".length
é interpretado como new String("a").length
. Os objetos Number
, Boolean
e Symbol
também, magicamente, adicionam propriedades aos seus respectivos tipos primitivos.
Apesar de parecer algo simples, conhecer particularidades e raciocinar um pouco a respeito dos tipos - e suas respectivas operações - ajudará a compreender alguns comportamentos da linguagem.
Checagem dinâmica de tipo
Os tipos são um dos principais pilares de uma linguagem. Muitos dos erros de execução em JavaScript, apesar de não percebermos, são erros de tipagem. Quando multiplicamos um number
por uma string
, silencionamente, terminamos com um indesejado Not a Number
. Já, quando chamamos uma propriedade que não está definida, levamos o erro undefined is not a function
, acessamos uma propriedade cujo valor não está definido (e isto, por definição da linguagem, retorna o valor undefined
) e presumimos que este valor tratava-se de uma função. Boom!
Outro deslize comum é quando tentamos alterar ou ler uma propriedade de um null
ou undefined
. Lembre-se de que não existem os objetos Null
ou Undefined
para salvar você aqui, o resultado destas operações é um erro. Ah! e tem também todas as vezes que esquecemos de cuidar bem do this
e confundimos qual o tipo do valor que está nele.
Um bom sistema de tipos ajuda a evitar erros grosseiros de programação. Como você já deve saber, o JavaScript é uma linguagem interpretada e dinâmica, o que obriga o sistema de tipos a funcionar muito tardiamente, apenas no momento da execução do código. A linguagem, às vezes, até tenta ajudar, convertendo um tipo para outro de forma discreta. Você, felizmente, se apoia nestas conversões ao tentar multiplicar 2
com "3"
e, por outro lado, se descabela ao tentar somar dois valores quando um deles trata-se de uma string
.
A mudança do tipo de um valor ou coerção em JavaScript é um assunto vasto e que leva muitos a advogarem pelo uso irrestrito de ===
. Acredito que o ponto não seja bem esse. O artigo Fixing Coercion, Not The Symptoms fala muito mais a respeito.
Em suma, tipos em JavaScript são um alvo móvel, é meio difícil acertá-los. Além de não ser possível prever e/ou garantir o tipo de uma variável, a linguagem é fracamente tipada e este tipo pode mudar.
Checagem estática de tipo
Uma checagem de tipos estática garante que seu programa esteja correto - pelo menos sintaticamente - antes mesmo da sua execução. Já existem algumas alternativas para se anotar os tipos dos valores e, assim, evitar que você chateie o usuário ao tentar multiplicar um valor por undefined
.
A modelagem de um produto e seu cálculo de preço é o exemplo que você vê abaixo. Entre a linha 4 e 6, definimos quais os tipos das propriedades da nossa classe com anotações compatíveis com Flow. O restante do código é puramente JavaScript. Mas repare como existe uma incompatibilidade no valor definido para tax
na linha 19. Um deslize desse iria terminar em um preço NaN
. O analisador do Flow deixa você saber disso já durante a escrita desse código.
/* @flow */ class Product { name: string cost: number tax: number constructor (name, cost, tax) { this.name = name this.cost = cost this.tax = tax } price() { return this.cost * (1 + this.tax) } } const item = new Product("Banana", 2, "%30")
Os tipos que podem ser anotados passam por todos os primitivos, objetos, construtores/classes (Date
, Array
, ...) e valores literais. Existem ainda tipos avançados como any, para denotar todos os tipos; nullable ou maybe, para indicar um tipo ou null
; e também as uniões e intersecções de tipos e interfaces. Na minha opinião, as anotações mais poderosas são as de novos tipos ou aliases, que permitem esclarecer as propriedades de um objeto; interfaces, para declarar apenas alguns métodos e ou propriedades pertinentes a um contexto; e generics. Este último permite indicar o tipo dos valores de um array ou do retorno de uma Promise, por exemplo. Segundo Kris Jenkins, através da definição dos tipos, é possível também prever problemas no design do código. Uma função que retorna vários diferentes tipos pode estar mal escrita. A criação de novos tipos pode ser útil para definir as entidades do seu problema. De toda forma, a anotação de tipos é um ótimo recurso para a escrita de códigos mais seguros e de fácil compreensão. A Type System is, first and foremost,
a communication mechanism - Sarah Mei Além do Flow, existe a linguagem TypeScript, que é estaticamente tipada e com sintaxe semelhante ao JavaScript. Vale lembrar que código Flow ou TypeScript precisa ser convertido para JavaScript antes de ser executado na máquina do usuário. Além de tudo, embora exija um certo esforço, o estudo To Type or Not to Type: Quantifying Detectable Bugs in JavaScript conclui que um analisador estático é capaz de detectar cerca de 15% dos bugs em produção de um código já coberto por testes. Dados externos ao seu código Programas em geral e, em especial, código de interface recebem com frequência valores externos. Nestes casos, é preciso definir um contrato discriminando os tipos dos valores destes dados. O código TypeScript abaixo, por exemplo, indica que o retorno de uma determinada API trata-se de uma lista de strings
: interface ItemsResponse { results: string[] } http.get<ItemsResponse>('/api/items') .subscribe(data => { this.results = data.results })
A partir desta anotação, o restante do código é analisado estaticamente para assegurar que tudo está de acordo com base nestes tipos. Mas veja bem, o analisador estático garante que tudo funcione, desde que este contrato seja respeitado. Caso a API do código acima retorne algo diferente de strings
, o risco de termos um erro em tempo de execução passa a ser alto. Todas as funções que retornam dados externos anotam estes valores como sendo do tipo any
. Por definição, este tipo pode ser posteriormente convertido para qualquer outro. A própria documentação do Flow indica que o uso de any
é inseguro e deve ser evitado, mas tratando-se de dados externos, parece não haver nada que possa ser feito. Parece. Elm Nos últimos tempos, uma linguagem para a web tem chamado a atenção por, dentre outras razões, prometer não possuir erros de execução. Elm é uma linguagem estaticamente tipada com sintaxe e paradigma bem diferentes do JavaScript. Revisitando (abaixo) a modelagem de produto e cálculo de preço, repare como a abordagem do problema é bem diferente. As linhas 1 a 5, 7, 11 e 14 são apenas responsáveis por definir e anotar os tipos. As linhas 8 e 9 definem o cálculo global de preço e a linha 12, o produto de exemplo. Não existem classes. Por fim, o cálculo do preço na linha 15. type alias Product = { name : String , cost : Float , tax : Float } price : Product -> Float price {cost,tax} = cost * (tax + 1) item : Product item = { name = "Banana", cost = 2, tax = 0.3 } itemPrice : Float itemPrice = price item -- 2.6 : Float
Porém, um dos recursos mais pertinentes a este texto é a definição de decoders. Elm obriga você a definir e a testar um contrato para dados vindos do exterior. Ao ser executado, o decoder pode retornar um Err
com a mensagem de erro, como na linha 6 abaixo; ou um Ok
com os dados, linha 10. import Json.Decode exposing (..) resultsDecoder = field "results" (list string) decodeString resultsDecoder "bla" Err "Given an invalid JSON: Unexpected token b in JSON…" : Result.Result String (List String) decodeString resultsDecoder "{ \"results\": [\"1\", \"2\"] }" Ok ["1","2"] : Result.Result String (List String)
O programa deve, então, tratar dos dois casos, que podem ocorrer em tempo de execução, sempre que um valor passa por um decoder: o caso Ok
e o caso Err
. Pronto, este é um dos segredos de não existir erros em execução quando você programa Elm. JavaScript contra-ataca O ecossistema JavaScript já possui bibliotecas para checar o tipo de valores em tempo de execução. O código abaixo permite disparar uma exceção, caso o retorno daquela API a qual fomos apresentados lá atrás não retorne strings
: var t = require("tcomb") const Results = t.interface({ results: t.list(t.String) }, "Results") Results({ results: ["1", "2"] }) Results({ results: [2] }) // TypeError: [tcomb] Invalid value 2 supplied to Results/results: Array<String>/0: String
Bibliotecas como tcomb e typify checam tipos em tempo de execução. Inclusive, cheguei a rascunhar um código aplicando o mesmo conceito de Ok
e Err
do Elm em JavaScript. Agora que toda a problemática foi apresentada, saiba que basta você cobrir todos os possíveis casos dentro do escopo do seu código, utilizando qualquer linguagem de programação que você não terá erros em tempo de execução. Anotações de tipos e checagem de valores externos em tempo de execução são grandes aliados. Imutabilidade Tipos primitivos no JavaScript são imutáveis. Isto significa que, uma vez criada uma string, ela jamais poderá ser modificada. Códigos como 'string'[0] = 'bla'
irão apenas falhar silenciosamente. O exemplo a seguir também falha silenciosamente porque, como já vimos, o interpretador cria um objeto Number
temporário para a linha 2 que não existe mais na linha 3. var num = 4 num.bla = 5 num.bla // it returns undefined
Loucuras à parte, em linhas gerais, objetos em JavaScript são mutáveis. E isto, de certa forma, é tão prático quanto pode ser um problema. O cenário abaixo ilustra, na linha 3, uma mutação indesejada que pode ocorrer dentro de um código de terceiros ou, até mesmo, em um código que você escreveu ontem. const item = new Product('Banana', 2, .3) const weird = (item) => item.price = () => 0 weird(item) item.price() // it returns 0
Outro fator interessante é que, trabalhando com valores imutáveis, fica difícil identificar quando há mudanças. Em uma árvore de componentes de interface, por exemplo, todos os componentes precisarão ser checados mesmo quando uma pequena mudança ocorrer. Inclusive, já existiu uma especificação para escutar estas alterações de um objeto que foi descontinuada. Código que pratica mutações tende a ser mais complexo e ferir boas práticas. O código abaixo exemplifica como é possível alterar dados de um usuário a partir da criação de novos objetos ao invés de aplicar mutações. Isto garante que componentes de interface e demais abstrações façam testes simples com ==
para descobrir se os dados mudaram. const newUser = { ...user, father: { ...user.father, name: "Peter" } }
Tratando-se de componentes de interface, o React oferece Pure Components e o Angular oferece OnPush Change Detection para trabalhar com dados imutáveis. O uso destes recursos garante melhor performance. Ainda, bibliotecas como Redux forçam o uso de dados imutáveis. Não há como fugir. Desafios Objetos literais com poucas propriedades podem ser recriados facilmente, como já vimos antes. Por outro lado, como será possível manipular um array com diversos valores? // item.push(42) without mutation: const itemNew = item.concat([42]) // item.unshift(42) without mutation: const itemNew = [42].concat(item) // item[10] = 42 without mutation: const itemNew = item.slice(0, 10)
.concat(42)
.concat(item.slice(11)) // item.splice(10, 0, 42) without mutation: const itemNew = item.slice(0, 10)
.concat(42)
.concat(item.slice(10))
Além de não ser nada intuitivo, os interpretadores e, até mesmo, o design da linguagem em si favorecem muito mais a mutação de código. Praticar mutações em um array é mais performático tanto em economia de memória quanto em rapidez do que tratá-lo como imutável. Persistent Data Structures Segundo a Wikipedia, uma Persistent Data Structure sempre preserva os estados anteriores quando sofrem alterações. Modificações não são aplicadas na estrutura em si, mas sim, geram novas estruturas atualizadas. Em outras palavras, são estruturas que reciclam valores para simular imutabilidade, como se fosse um repositório Git. Immutable é uma das bibliotecas que traz um conjunto de estruturas deste tipo. Abaixo, você encontra um exemplo que irá retornar false
para qualquer comparação de igualdade entre os items
. Wow! const item = Immutable.List(
[0, 1, 0, 1, 0, 0, 1, 1, 1, 0, 1, 1, …]) const item2 = item.push(42) const item3 = item.unshift(42) const item4 = item.set(10, 42) const item5 = item.insert(10, 42)
E, a seguir, o exemplo de alteração de dados do usuário: const rawUser = { name: "Paul", father: { name: "John" }, mother: { name: "Ana" } } const user = Immutable.fromJS(rawUser) const newUser = item.setIn(
["father", "name"], "Peter"
)
Os únicos detalhes em relação à Immutable são a dificuldade de debugging e a necessidade de se utilizar uma nova API para manipular os dados. O material Using Immutable.JS with Redux é um bom guia para aprender a trabalhar com a biblioteca. Além disto, as palestras Immutable data structures for functional JS e Immutable Data and React abordam o seu funcionamento a fundo. Linguages imutáveis Algumas linguagens já trazem imutabilidade como parte do seu DNA. Este, por exemplo, é o caso de Elm: imutabilidade é um conceito fundamental das linguagens puramente funcionais. Os artigos Is your JavaScript function actually pure? e Selecting a platform [building purely functional user interfaces] são ótimas referências para compreender melhor este assunto tão complexo e rico. Espero que o texto e a palestra tenham inspirado você. A intenção aqui foi muito mais apontar possibilidades emergentes, porém promissoras, do que advogar por uma tecnologia ou outra. Descubra o máximo do mundo que existe lá fora. Estudar linguagens, bibliotecas e frameworks novos, mesmo que por simples curiosidade, o tornam um programador JavaScript [baunilha] melhor.