Quando a gente pensa em JavaScript, geralmente a ideia geral é de alguma linguagem que é extremamente simples e, por algum motivo, parece estar em todos os lugares que você olha, sem exceção.

Mas enquanto o JavaScript é sim simples quando você já tem uma certa experiência com desenvolvimento, isso não é sempre verdade, principalmente se você está começando agora a sua jornada no maravilhoso mundo da programação.

Nesse artigo eu vou trazer algumas das práticas consideradas "obsoletas" ou "más", quando a gente está fazendo algum código em JavaScript. Mas, é importante ressaltar também que, por mais que essas práticas sejam consideradas más práticas, isso não significa que não haja um caso de uso legítimo para algumas delas.

Eu digo isso porque é importante que a gente note que as coisas não são bicromáticas em nenhum tipo de assunto, ao invés de ser algo preto no branco, estamos falando em algo que seriam tons de cinza. Tudo que fazemos em desenvolvimento de software tem um motivo e existem casos onde vamos sim precisar usar algumas dessas técnicas, seja por questão de performance, por questão de compatibilidade e etc.

Então já fica a dica ai, você provavelmente vai ver alguma coisa assim – ou até mesmo vai precisar fazer algo assim – em algum momento da sua vida. Seja para suportar um produto antigo, seja para melhorar a performance, o que for.

Usar var em 2022

Eu já começo com a primeira e mais absurda de todas as coisas que você vai ver em código JavaScript, o var.

A única explicação possível para alguém estar usando isso ainda manualmente é para compatibilidade forçada com algum tipo de runtime que, provavelmente, deixou de ser usado faz, pelo menos, seis anos.

"Mas qual é o problema do var? 😱"

Quando a gente fala de alocação de variáveis em JavaScript (ou em qualquer outra linguagem pra esse fim) com o var existem dois tipos de escopo – como eu expliquei nesse artigo aqui – o escopo global e o escopo de função.

O escopo global é acessível não só para o que está dentro da função, mas também pra tudo que está fora dela, e o escopo de função, como o nome diz, só está acessível dentro da função que a variável está declarada.

Isso sozinho é um grande problema porque você pode errar muito fácil quando declara uma variável que é acessível pra todo mundo, mas, para completar a sequencia de erros, um comportamento muito interessante do var é que ele não lança nenhum tipo de erro quando você redeclara uma variável já existente (como a gente vê hoje com const e let por exemplo). O problema é que, ao invés de redeclarar a variável do mesmo jeito e substituir o valor, o engine só não faz nada.

Isso pode levar a um comportamento muito confuso e bugs bizarros que podem surgir por lógica quebrada por conta de uma variável com o mesmo nome.

O que você pode fazer hoje

Use let e const – de preferência const – já que esses dois tipos de declarações não ficam presos somente aos escopos global e função, mas sim aos escopos de cada bloco, o que a gente chama de escopo léxico, ou seja, uma variável só vai existir dentro do bloco de código que ela foi declarada e nada mais, isso evita já um grande problema de vazamento de valores.

Além disso, variáveis do tipo const são para valores imutáveis, então elas não podem ser reassociadas sem ter um erro, e nenhuma das duas permite a redeclaração com o mesmo nome.

Acreditar na coerção de tipos

Um tempo atrás eu comecei uma thread legal no Twitter sobre coerção de tipos, a característica que, ao mesmo tempo, é a maravilha e a destruição não só da linguagem como um todo mas também motivo de divisão da comunidade de devs em duas partes: A galera que gosta de JavaScript e a galera que não gosta dele.

Uma pequena introdução para quem nunca ouviu sobre isso. A coerção de tipos é uma funcionalidade típica de linguagens dinamicamente tipadas – como o JavaScript, Python, Ruby... – ela faz com que você possa fazer o seu código sem se preocupar com os tipos das variáveis, ou seja, diferente de outras linguagens como C#, Java, C e família.

Isso pode ser um super poder incrível pra quem está programando, porque você é muito mais ágil e não precisa se preocupar se um tipo vai ser compatível com o outro porque, se ele não for, a linguagem vai converter ele automaticamente pra você, ou seja, o compilador vai coagir aquela variável para o tipo desejado.

Mas a sacada é que ela pode ser um poder pra quem sabe todas as regras de coerção de tipos de cor, o que não é verdade pra quase ninguém (nem mesmo quem trabalha no core da linguagem, e muito menos devs muito mais experientes), então confiar demais na coerção de tipos pra poder converter o que você está mandando pra linguagem para o tipo certo não é muito a melhor coisa a se fazer.

Acho que o exemplo mais clássico disso, fora o que a gente já mostrou na thread é a famosa "soma de 1+1". Quase todos os operadores (como + - / * ==) vão automaticamente converter os tipos das suas contrapartes, então se a gente tentar fazer algo assim:

console.log("1" + "1") // "11"
console.log("2" - "1") // 1

console.log('' == 0) // true
console.log(true == []) // false
console.log(true == ![]) // false

Vamos ver que temos umas saídas bem estranhas, por que ele somou as duas strings mas subtraiu os dois números? Por que [] não é true? E várias outras perguntas que eu não vou responder aqui.

O fato é: Confiar demais na coerção é ruim, não confiar, também é ruim.

Se você confia demais na coerção de tipos do JavaScript, vai acabar provavelmente com um código completamente ilegível pra qualquer ser humano, porque o JavaScript não vai te dar nenhuma pista sintática do que está acontecendo no seu código (essa, inclusive, é a razão de supersets como o TypeScript terem sido criados).

Por outro lado, se você não confia na coerção de tipos do JavaScript, então é melhor não usar JavaScript de forma alguma. Porque se você for converter manualmente – e sim, é possível – todos os tipos para os tipos que você quer, é melhor usar uma linguagem tipada naturalmente.

O que fazer?

Não só tire proveito da coerção, mas entenda como ela funciona. É fácil falar que o compilador é estranho, mas a história dessa linguagem mostra o porquê ele se comporta dessa maneira e porque ele vai continuar se comportando assim pra sempre.

Além disso, adicione uma conversão explicita de tipo quando perceber que a sua variável pode ser ambígua, por exemplo:

let qualquerCoisa = // algum valor recebido

let stringA = a.tostring()
let numeroA = Number(a)
let boolA = Boolean(a)

Confie na coerção para criação e recebimento, mas só confie nela pra conversões pontuais se você tiver absoluta certeza do resultado final, caso contrário seu código não vai ser muito resistente a edge cases.

Achar que arrow functions são iguais a funções comuns

Por mais que elas façam as mesmas coisas e tenham quase os mesmos nomes, arrow functions e funções comuns são coisas completamente diferentes.

Eu já perdi a conta da quantidade de vezes que vi devs serem reprovados em testes lógicos em entrevistas por conta dessa pergunta. E eu mesmo, participando desses processos, já fiz ela inúmeras vezes. E o mais impressionante é que muita gente acha que são as mesmas coisas, muita gente fala que é só um sugar syntax em cima de funções, mas não é!

Existem muitas diferenças entre uma função normal como function foo () {} e uma arrow function do tipo () => {}. E não é nem como se isso estivesse oculto nas documentações do JavaScript, isso está completamente aberto e bem visto, inclusive é algo extremamente comentado.

Algumas diferenças básicas entre essas funções (existem algumas outras aqui):

  • Arrow functions não tem o próprio contexto, ou seja, o valor do this dentro da função vai ser o valor do escopo imediatamente superior a ela, então se você declarar uma arrow function dentro de outra função, o valor de this vai ser a referência da função pai. Funções normais tem contexto próprio, então se você declarar uma função dentro de outra função, o valor de this da função filha vai ser completamente diferente do valor this da função pai. Por isso que, lá nos primórdios, a gente salvava uma var self = this, porque a gente precisava passar o contexto de outro local para a função interna.
  • Arrow functions não possuem a variável de sistema arguments, essa é uma variável especial no JavaScript que retorna tudo que foi passado para a função em forma de array. Isso era muito comum antigamente quando usávamos essa técnica pra construir argumentos variádicos (que podem ter um número variável de valores). Isso nem é tão necessário hoje, tanto até porque podemos fazer quase a mesma coisa com os parametros do tipo rest.
  • Arrow functions não podem ser construtores válidos. Algo que vamos falar mais para frente é sobre protótipos, e protótipos são uma forma de herança. No início do JS, a unica forma de fazermos alguma coisa com heranças era usando construtores de funções, isso mesmo new MinhaFuncao() ia retornar uma instância daquela função, e ai podíamos alterar o protótipo dela como a gente quisesse. Isso não é possível em arrow functions, e também, apesar de ser possível, não é recomendado já que temos a estrutura de classes do JavaScript.

Isso são apenas algumas coisas, mas já é um grande passo pra poder entender quando usar e quando não usar funções diferentes em diferentes casos.

Ignorar o this

Acho que o this é o assunto mais não entendido do JavaScript, tanto é que eu escrevi um um artigo em 2018 e até hoje tem gente perguntando sobre isso.

O this é realmente complexo de entender quando você entra na linguagem, é uma das "peculiaridades" do JavaScript ter um contexto móvel. Se você já trabalhou um pouco mais com JS, então você já teve que lidar com coisas como this, .bind(), .call() e .apply().

O this tem basicamente 3 regras (créditos a Fernando Doglio por explicar tão bem):

  • Dentro de uma função, o this vai assumir o contexto daquela função, ou seja, o valor do contexto da instância da função. Se fosse um protótipo seria o valor do protótipo, mas isso não é mais tão comum.
  • Dentro de uma arrow function, ele vai assumir o valor do contexto do objeto pai, seja ele qual for, se você chamar uma função dentro de outra função, o this vai ser o this da função pai, se for diretamente na raiz, será o escopo global, se for dentro de um método, vai ser o contexto do método.
  • Dentro de métodos de classes, é o contexto daquele método, incluindo todas as propriedades da classe (que é o jeito que todo mundo que já trabalhou com OOP está mais acostumado)

No geral, o contexto é móvel, então ele pode ser facilmente substituído dentro de uma função, por métodos como o bind e o call:

class foo () {
	constructor (arg1, arg2) {
        this.arg1 = arg1
        this.arg2 = arg2
    }
}

function bar () {
    console.log(this.arg1, this.arg2)
}

const foo1 = new foo('Lucas', 'Santos')
const foo2 = new foo(true, 42)

bar.bind(foo1)() // Lucas Santos
bar.call(foo2) // true 42

Usando esses métodos podemos extrair o contexto e passar o valor do this que queremos para qualquer objeto. Isso é ainda muito utilizado quando estamos lidando com sistemas que injetam código dentro de outros sistemas sem precisar alterar a sua implementação.

Não usar comparadores estritos

Outro problema que pega muita gente é que o uso do == ao invés do ===. Lembra o que eu falei sobre a coerção de tipo? Pois é, aqui é onde ela brilha ainda mais.

Operadores como o == vão comparar somente os valores dos dois lados, e para isso acontecer, ele precisa converter os dois para o mesmo tipo para que eles possam ser comparados pra início de conversa. Então se você passar uma string de um lado e um número do outro, o == vai tentar ou converter ambos para string ou ambos para números.

Isso não acontece com o ===, porque ele compara não só o valor, mas também o tipo, portanto, a coerção não acontece. Então você tem muito menos chances de cair em um erro bizarro de coerção quando usa operadores estritos de comparação.

Ignorar os erros em callbacks

Isso não é uma má prática só em JavaScript, mas em qualquer linguagem, mas como o JS permite que existam erros dentro de callbacks como parâmetros que podem ou não ser tratados, isso acaba sendo válido, mesmo que a gente não use tanto callbacks quanto antigamente.

Em casos que a gente tem algo como:

umaFuncaoComCallback((err, data) => {
  return data
})

Onde o código é perfeitamente válido, mas o erro não é tratado, vai ter muitos erros no futuro, principalmente pelo fato de que estes erros podem não ser oriundos da sua própria aplicação, então a lógica pode continuar rodando mas os valores que ela receber vão estar completamente diferentes do esperado, por exemplo, quando você recebe uma chamada de uma API ou algo do tipo.

Erros em callbacks, por mais escassos que eles sejam hoje, devem ser sempre tratados:

umaFuncaoComCallback((err, data) => {
  if (err) throw err
  return data
})

Usar callbacks

E ai caímos na próxima "má prática", que não é tão má prática assim dependendo do caso, é o uso de callbacks.

Temos uma explicação sensacional nesse artigo sobre por que callbacks e promises são completamente diferentes. Mas o resumo da opera é que, com callbacks, o controle do seu código pode ser perdido muito facilmente. Um dos motivos é o famoso callback hell onde um callback leva outro callback que leva outro callback e assim por diante.

O outro motivo é que, como callbacks são funções completas, você precisa passar o controle das ações que você vai tomar quando o callback for completo para o executor da tarefa, ou seja, o callback, se acontecer algum problema dentro do callback é como se você estivesse em um nível mais abaixo do código, com um contexto completamente diferente.

Por isso o uso de Promises, além de muito mais legível, é preferível, principalmente quando estamos usando async/await, porque ai podemos delegar a "promessa" de uma execução para um executor e, quando esse executor finalizar a execução, vamos ter a saída de forma concreta e ai poderemos executar a próxima ação.

Promises são tão importantes que eu escrevi dois artigos sobre elas e ainda sim eles recebem muitas visitas e muitas perguntas.

Promises também podem causar "promise hells", e também estão sujeitas a delegação de controle, mas é uma questão de uso. Você pode usar promises para criar um novo contexto de execução enquanto o contexto anterior ainda está executado, como:

function promise () {
	return new Promise((resolve, reject) => {
    	setTimeout(resolve, 3000)
    })
}

promise().then((data) => {
	// outro contexto de execução
})

//código continua

E por isso é importante saber quando usar then e quando usar await, porque você pode sim criar processamentos em threads diferentes paralalemente usando apenas Promises, sem precisar bloquear o processo principal, digamos que você queira logar o progresso de uma função conforme ela vai progredindo, mas a tarefa não tem nada a ver com a tarefa original, então ela pode ser executada em um contexto separado.

Já quando temos que fazer uma chamada a um banco de dados, essa chamada tem a ver com a nossa lógica atual, então a gente não pode continuar executando o programa, temos que parar, esperar (sem bloquear o event loop) e depois trabalhar com o resultado.

Usar técnicas "arcaicas"

Honestamente, ninguém tinha a menor ideia que o JavaScript seria tão famoso assim. Então durante o curso da vida da linguagem, as aplicações criadas com ele foram evoluindo muito mais rápido que a linguagem em si.

Como resultado disso, a galera foi criando "gambiarras" pra poder solucionar os problemas. E isso foi ficando nos códigos até hoje, por exemplo, o uso de array.indexOf(x) > -1 para poder identificar elementos não presentes no array, quando hoje já é possível usar array.includes(x).

Esse artigo tem um guia muito legal de como passar pelos códigos antigos e "atualizá-los".

Não usar "Zero Values"

Zero values são uma técnica que é muito adotada por Golang, onde você sempre inicia uma variável com um valor inicial, um valor zero.

No JavaScript, qualquer variável não inicializada vai assumir o valor de undefined, mas ao mesmo tempo temos valores null, que podem ser atriuídos a uma varíavel para dizer que ela não tem valor nenhum.

Geralmente é uma má prática iniciar como undefined, porque temos que compara esses valores diretamente com undefined, caso contrário podemos estar acidentalmente encontrando um null e tratando como undefined.

Fora que o JavaScript tem uma série de métodos para evitar comparar propriedade === undefined como o if ('prop' in objeto). Tente sempre utilizar valores iniciais, pois assim também fica mais simples para mesclar objetos com valores padrões como {...valorPadrao, ...novosValores}.

Não seguir um estilo de código

Este provavelmente é não só uma má prática mas uma falta de respeito com outros colegas, se você estiver trabalhando em equipe.

Existem muitos estilos de códigos conhecidos, como o do AirBnB, o do Google e, o meu preferido, o Standard. Por favor, use eles, isso torna o processo muito mais simples e muito mais fácil de ler para outras pessoas na equipe, sem falar que eles também facilitam muito na hora de debugar e entender o que está acontecendo.

Se você sempre esquece, não tem problema! Use ferramentas de linting como o ESLint e o Prettier, se quiser, tem até um repositório template que eu criei que já tem isso tudo configurado.

Mexer nos protótipos

Herança prototípica é algo que é bastante complexo e bastante avançado até pra quem já tem muito tempo de casa.

Há muito tempo eu escrevi um artigo sobre como funcionam os protótipos e a herança no JavaScript, a ideia é que tudo é um objeto, cada objeto tem seu protótipo, que também é um objeto, este protótipo é uma referência ao objeto que criou o objeto atual, então basicamente ele tem todos os métodos daquele objeto.

Por exemplo, um array simples já vai ter todos os métodos comuns filter, map, reduce e etc. Mas isso, na verdade, vem do Array.prototype, que é o objeto que é passado para o seu array quando ele é criado. O jeito como a herança funciona é que o JS vai procurar em todos os protótipos, desde o mais alto (que é o atual) até o mais baixo (que é o de origem), pelo nome da função, se ele não encontrar em nenhum lugar, essa função não existe.

Se ficou confuso, normal, mas aconselho que você leia o artigo para entender melhor, já que aqui estou só dando a ideia base.

Antigamente era muito comum usarmos o protótipo para injetar uma série de métodos dentro da nossa função para que ela se comportasse como uma classe, já que todas as instâncias daquela função teriam os mesmos protótipos, mas isso não é mais verdade hoje.

Evite ao máximo modificar protótipos, a não ser que você realmente saiba o que está fazendo, caso contrário você pode causar problemas muito sérios na sua aplicação, já que você está mexendo nas formas que definem seus objetos.

Conclusão

Existem muitas más práticas, algumas são necessárias, muitas delas vão existir no código que você está trabalhando, mas nenhuma é irreversível. Então cabe a nós deixar o código melhor do que encontramos quando chegamos.

Se você tiver mais dicas, é só me chamar em qualquer uma das minhas redes sociais :D