Imutabilidade é um dos conceitos mais poderosos e transformadores na programação moderna, especialmente em JavaScript. Em essência, um valor imutável é aquele que não pode ser alterado após sua criação. Quando você adota a imutabilidade, você não modifica dados existentes; em vez disso, cria novos dados a partir deles. Isso pode parecer ineficiente à primeira vista, mas traz benefícios enormes em previsibilidade, rastreabilidade e segurança do código.

Nesta aula, vamos explorar por que a imutabilidade importa, desmistificar a ideia de que const torna tudo imutável, conhecer as principais técnicas para trabalhar de forma imutável e, finalmente, dominar o structuredClone, uma ferramenta moderna e nativa para clonagem profunda. Prepare-se para elevar seu nível de programação JavaScript a um patamar mais profissional.

Por que importa

Imagine que você está trabalhando em um sistema de carrinho de compras. Você tem um objeto que representa o carrinho, com uma lista de itens. Se você modificar diretamente esse objeto, qualquer outra parte do código que esteja observando o carrinho verá a mudança imediatamente. Isso pode ser desejável em alguns casos, mas em aplicações complexas, com múltiplas funções e estados, essa mutação direta leva a efeitos colaterais difíceis de rastrear. Um bug pode surgir em um lugar completamente diferente, porque alguém alterou um objeto que era compartilhado.

Com a imutabilidade, cada operação que alteraria o estado retorna um novo estado, deixando o original intacto. Isso traz benefícios como:

  • Previsibilidade: Se uma função não altera seus argumentos, você pode confiar que ela produzirá o mesmo resultado para os mesmos dados de entrada, sem surpresas.
  • Rastreabilidade: Como nada é modificado, você pode comparar versões anteriores de dados, facilitando o debug e a implementação de funcionalidades como 'desfazer' e 'refazer'.
  • Concorrência segura: Em ambientes com threads ou processos paralelos, dados imutáveis não sofrem condições de corrida.
  • Testabilidade: Testes unitários se tornam mais fáceis, pois você pode fornecer dados de entrada e verificar a saída sem se preocupar com estado global.

Na prática, a imutabilidade é um pilar em bibliotecas como Redux e em paradigmas como programação funcional. Ela não é apenas um conceito acadêmico, mas uma ferramenta concreta para escrever código mais robusto.

const não é imutável

Muitos programadores iniciantes acreditam que usar const torna um valor imutável. Isso é um equívoco comum. const apenas impede a reatribuição da variável, ou seja, você não pode fazer const x = 1; x = 2;. No entanto, se o valor for um objeto ou array, suas propriedades e elementos ainda podem ser alterados.

Veja um exemplo:

const pessoa = { nome: "João", idade: 30 };

// Isso é permitido:
pessoa.idade = 31;

// Isso também é permitido:
pessoa.email = "joao@example.com";

// Isso NÃO é permitido:
// pessoa = { nome: "Maria" }; // Erro!

console.log(pessoa); // { nome: "João", idade: 31, email: "joao@example.com" }

Da mesma forma, arrays com const podem ser modificados:

const numeros = [1, 2, 3];
numeros.push(4);
numeros[0] = 10;
console.log(numeros); // [10, 2, 3, 4]

Portanto, const é apenas uma garantia de que a variável apontará para o mesmo objeto, mas o objeto em si pode ser mutado. Para alcançar a imutabilidade, você precisa de técnicas específicas que veremos a seguir. Lembre-se: a imutabilidade é uma escolha de design, não um recurso da linguagem por padrão.

Técnicas

Existem várias técnicas para trabalhar com dados imutáveis em JavaScript. Vamos explorar as mais comuns e práticas.

1. Object.freeze()

Object.freeze() impede que propriedades sejam adicionadas, removidas ou alteradas em um objeto. No entanto, o congelamento é raso: se o objeto tiver objetos aninhados, eles ainda podem ser mutados.

const pessoa = Object.freeze({ nome: "João", endereco: { cidade: "São Paulo" } });

// Isso falha em modo estrito (ou silenciosamente em modo não estrito):
pessoa.nome = "Maria"; // não altera
console.log(pessoa.nome); // "João"

// Mas o objeto aninhado ainda pode ser alterado:
pessoa.endereco.cidade = "Rio";
console.log(pessoa.endereco.cidade); // "Rio" (mutado!)

Para congelar profundamente, você pode usar uma função recursiva ou deepFreeze de bibliotecas como Lodash. Mas cuidado: Object.freeze também impede a reatribuição de propriedades, mas não impede a mutação de objetos internos.

2. Operador de espalhamento (spread)

O spread operator (...) permite criar cópias superficiais de arrays e objetos. É uma técnica muito útil para criar novos objetos sem alterar o original.

const original = { a: 1, b: 2 };
const copia = { ...original, b: 3 };
console.log(original); // { a: 1, b: 2 }
console.log(copia);    // { a: 1, b: 3 }

const arr = [1, 2, 3];
const novoArr = [...arr, 4];
console.log(arr);     // [1, 2, 3]
console.log(novoArr); // [1, 2, 3, 4]

No entanto, o spread é raso. Se o objeto tiver propriedades que são objetos, elas serão copiadas por referência.

const original = { a: 1, b: { c: 2 } };
const copia = { ...original };
copia.b.c = 99;
console.log(original.b.c); // 99 (mutado!)

3. map, filter, reduce

Métodos de array como map, filter e reduce retornam novos arrays, sem modificar o original. Eles são fundamentais para programação funcional e imutabilidade.

const numeros = [1, 2, 3, 4];

const dobrados = numeros.map(n => n * 2);
console.log(numeros); // [1, 2, 3, 4]
console.log(dobrados); // [2, 4, 6, 8]

const pares = numeros.filter(n => n % 2 === 0);
console.log(pares); // [2, 4]

const soma = numeros.reduce((acc, n) => acc + n, 0);
console.log(soma); // 10

Esses métodos são preferíveis a loops que modificam o array original, como push ou splice.

4. Imutabilidade em objetos aninhados

Para lidar com estruturas aninhadas, você pode combinar spread com atualizações em cada nível. Por exemplo:

const state = {
  user: { name: "Ana", address: { city: "SP" } },
  items: [1, 2, 3]
};

// Atualizar cidade de forma imutável:
const newState = {
  ...state,
  user: {
    ...state.user,
    address: {
      ...state.user.address,
      city: "RJ"
    }
  }
};

console.log(state.user.address.city); // "SP"
console.log(newState.user.address.city); // "RJ"

Essa técnica é chamada de 'atualização aninhada' e é trabalhosa, mas funciona. Para simplificar, existem bibliotecas como Immer que oferecem uma sintaxe mais elegante, mas vamos nos concentrar em técnicas nativas.

structuredClone

O structuredClone é uma função global introduzida no Node.js 17 e em navegadores modernos. Ela cria uma cópia profunda de um objeto, ou seja, todos os níveis de aninhamento são copiados de verdade, não por referência. Isso resolve o problema da clonagem rasa dos spreads e de Object.assign.

Exemplo:

const original = { a: 1, b: { c: 2, d: [3, 4] } };
const clone = structuredClone(original);

clone.b.c = 99;
clone.b.d.push(5);

console.log(original.b.c); // 2
console.log(original.b.d); // [3, 4]
console.log(clone.b.c);    // 99
console.log(clone.b.d);    // [3, 4, 5]

O structuredClone pode clonar quase todos os tipos de dados, incluindo Date, Map, Set, ArrayBuffer, e até mesmo objetos com referências circulares. Isso é incrivelmente útil para serializar e desserializar dados sem perder informações.

Limitações: ele não clona funções, símbolos, ou objetos do DOM. Também não preserva protótipos customizados. Mas para a maioria dos casos de dados, é perfeito.

Alternativas: antes do structuredClone, a clonagem profunda era feita com JSON.parse(JSON.stringify(obj)), mas isso tem problemas: não suporta Date, Map, Set, funções, e ignora propriedades undefined. O structuredClone é muito mais robusto.

Uso prático:

// Clonando um estado para não mutar o original
const estadoOriginal = { contador: 0, items: [{ id: 1, nome: "A" }] };

function adicionarItem(estado, novoItem) {
  const clone = structuredClone(estado);
  clone.items.push(novoItem);
  return clone;
}

const novoEstado = adicionarItem(estadoOriginal, { id: 2, nome: "B" });
console.log(estadoOriginal.items.length); // 1
console.log(novoEstado.items.length);     // 2

Essa abordagem garante que o estado original não seja alterado, promovendo a imutabilidade de forma simples e eficiente.

Boas práticas e observações finais

A imutabilidade não é uma bala de prata. Em alguns casos, a mutação pode ser mais performática, especialmente em operações de baixo nível ou em loops grandes. No entanto, na maioria das aplicações, os benefícios em manutenibilidade superam as perdas de performance. Você pode combinar técnicas: use Object.freeze para proteger objetos em tempo de desenvolvimento, use spread para cópias rasas, e structuredClone para clonagens profundas quando necessário.

Lembre-se de que a imutabilidade é uma disciplina: você deve evitar métodos que mutam, como push, splice, sort (que muta o array original), e preferir concat, slice, e [...arr].sort() para criar novos arrays. Sempre que for atualizar um estado, crie um novo objeto/array.

Em frameworks como React, a imutabilidade é essencial para o correto funcionamento do estado e do re-render. Ao seguir essas práticas, você estará preparado para construir aplicações mais confiáveis e escaláveis.

Exercícios

  1. Explique, com suas palavras, por que const não garante imutabilidade. Dê um exemplo de código que demonstre isso.
  2. ✓ Resposta: const impede a reatribuição da variável, mas não torna o valor imutável. Por exemplo:
    const arr = [1, 2, 3]; arr.push(4); // isso é permitido
    O array foi modificado, mesmo sendo const.
  3. Qual é a diferença entre clonagem rasa e profunda? Dê um exemplo de cada usando spread e structuredClone.
  4. ✓ Resposta: Clonagem rasa copia apenas o primeiro nível, enquanto a profunda copia todos os níveis. Exemplo:
    const original = { a: { b: 1 } };
    const rasa = { ...original };
    const profunda = structuredClone(original);
    rasa.a.b = 2;
    console.log(original.a.b); // 2 (mutado)
    profunda.a.b = 3;
    console.log(original.a.b); // 2 (não mutado)
  5. Escreva uma função adicionarItemImutavel(carrinho, item) que recebe um array de itens e um novo item, e retorna um novo array sem modificar o original. Use spread.
  6. ✓ Resposta:
    function adicionarItemImutavel(carrinho, item) {
      return [...carrinho, item];
    }
    // Exemplo:
    const carrinho = ["maçã"];
    const novoCarrinho = adicionarItemImutavel(carrinho, "banana");
    console.log(carrinho); // ["maçã"]
    console.log(novoCarrinho); // ["maçã", "banana"]
  7. Use Object.freeze para congelar um objeto e tente alterar uma propriedade. O que acontece? Explique.
  8. ✓ Resposta:
    const obj = Object.freeze({ a: 1 });
    obj.a = 2;
    console.log(obj.a); // 1
    // Em modo estrito, lança TypeError; em modo não estrito, falha silenciosamente.
  9. Dado o objeto state abaixo, crie uma função atualizarCidade(state, novaCidade) que retorna um novo estado com a cidade atualizada, sem mutar o original. Use structuredClone ou spread aninhado.
  10. ✓ Resposta:
    const state = {
      user: { name: "Ana", address: { city: "SP" } }
    };
    
    function atualizarCidade(state, novaCidade) {
      return {
        ...state,
        user: {
          ...state.user,
          address: {
            ...state.user.address,
            city: novaCidade
          }
        }
      };
    }
    // ou com structuredClone:
    function atualizarCidadeClone(state, novaCidade) {
      const clone = structuredClone(state);
      clone.user.address.city = novaCidade;
      return clone;
    }
    console.log(state.user.address.city); // "SP"
    const novoState = atualizarCidade(state, "RJ");
    console.log(novoState.user.address.city); // "RJ"

Referências