loading...
Cover image for TypeScript: Imutabilidade em tempo de compilação em TypeScript

TypeScript: Imutabilidade em tempo de compilação em TypeScript

oieduardorabelo profile image Eduardo Rabelo ・10 min read

O TypeScript nos permite decorar o ECMAScript compatível com as especificações com informações de tipo onde podemos analisar e gerar JavaScript usando um compilador dedicado. Em projetos de larga escala, esse tipo de análise estática pode detectar possíveis erros antes de recorrer a longas sessões de depuração, e muito menos em produção. No entanto, os tipos de referência no TypeScript ainda são mutáveis, o que pode levar a efeitos colaterais indesejados em nosso software.

Neste artigo, veremos possíveis maneiras de proibir referências de serem mutadas, o que pode ser benéfico.

Precisa de uma atualização sobre imutabilidade em JavaScript? Leia o nosso guia, Imutabilidade em JavaScript.

Tipos Primitivos vs Tipos de Referência

JavaScript define dois grupos abrangentes de tipos de dados:

  • Primitivos: valores de baixo nível que são imutáveis (por exemplo, strings, números, booleanos etc.)
  • Referências: coleções de propriedades, representando um heap de memória identificável, que são mutáveis (por exemplo, objetos, arrays, Map etc.)

Digamos que declaramos uma constante, à qual atribuímos uma string:

const message = 'hello';

Como as strings são primitivas e, portanto, imutáveis, não podemos modificar diretamente esse valor. Só pode ser usado para produzir novos valores:

 console.log(message.replace('h', 'sm')); // 'smello' console.log(message); // 'hello' 

Apesar de invocar replace() no message, não estamos modificando sua memória. Estamos apenas criando uma nova string, deixando intacto o conteúdo original do message.

Tentar mutar os índices do message é um erro operacional por padrão, que lançará um TypeError no modo estrito:

'use strict';
const message = 'hello';
message[0] = 'j'; // TypeError: 0 is read-only

Observe que, se a declaração de message usar a palavra-chave let, poderemos substituir o valor para o qual ela resolve:

let message = 'hello';
message = 'goodbye';

É importante destacar que isso não é mutação. Ao invés disso, estamos substituindo um valor imutável por outro.

Referências Mutáveis

Vamos contrastar o comportamento dos primitivos com as referências. Vamos declarar um objeto com algumas propriedades:

const me = {
  name: 'James',
  age: 29,
};

Como os objetos JavaScript são mutáveis, podemos alterar suas propriedades existentes e adicionar novas:

me.name = 'Rob';
me.isTall = true;
console.log(me); // Object { name: "Rob", age: 29, isTall: true };

Diferentemente dos primitivos, os objetos podem ser diretamente alterados sem serem substituídos por uma nova referência. Podemos provar isso compartilhando um único objeto em duas declarações:

const me = {
  name: 'James',
  age: 29,
};

const rob = me;

rob.name = 'Rob';

console.log(me); // { name: 'Rob', age: 29 }

Os arrays JavaScript, que herdam do Object.prototype, também são mutáveis:

const names = ['James', 'Sarah', 'Rob'];

names[2] = 'Layla';

console.log(names); // Array(3) [ 'James', 'Sarah', 'Layla' ]

Qual é o problema das referências mutáveis?

Considere que temos um array mutável dos cinco primeiros números de Fibonacci:

const fibonacci = [1, 2, 3, 5, 8];

log2(fibonacci); // muda cada item, n, por Math.log2(n);
appendFibonacci(fibonacci, 5, 5); // anexa os próximos cinco números Fibonacci no array

Esse código pode parecer inofensivo na superfície, mas como o log2 modifica o array que recebe, nosso array de fibonacci não representará mais exclusivamente os números de Fibonacci, como o nome sugere. Ao invés disso, fibonacci se tornaria [0, 1, 1.584962500721156, 2.321928094887362, 3, 13, 21, 34, 55, 89]. Pode-se, portanto, argumentar que os nomes dessas declarações são semanticamente imprecisos, dificultando o acompanhamento do fluxo do programa.

Objetos pseudo-imutáveis ​​em JavaScript

Embora os objetos JavaScript sejam mutáveis, podemos tirar proveito de funcionalidades específicas para clonar referências aninhadas, com spread:

const me = {
  name: 'James',
  age: 29,
  address: {
    house: '123',
    street: 'Fake Street',
    town: 'Fakesville',
    country: 'United States',
    zip: 12345,
  },
};

const rob = {
  ...me,
  name: 'Rob',
  address: {
    ...me.address,
    house: '125',
  },
};

console.log(me.name); // 'James'
console.log(rob.name); // 'Rob'
console.log(me === rob); // false

A sintaxe spread também é compatível com array:

const names = ['James', 'Sarah', 'Rob'];
const newNames = [...names.slice(0, 2), 'Layla'];

console.log(names); // Array(3) [ 'James', 'Sarah', 'Rob' ]
console.log(newNames); // Array(3) [ 'James', 'Sarah', 'Layla' ]
console.log(names === newNames); // false

Pensar em imutabilidade ao lidar com tipos de referência pode tornar o comportamento do nosso código mais claro. Revisitando o exemplo anterior do Fibonacci mutável, poderíamos evitar essa mutação copiando fibonacci para um novo array:

const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = [...fibonacci];

log2(log2Fibonacci);
appendFibonacci(fibonacci, 5, 5);

Ao invés de criar cópias no consumidor, seria melhor que o log2 e o appendFibonacci tratassem suas entradas como somente leitura, criando novas saídas baseadas nelas:

const PHI = 1.618033988749895;

const log2 = (arr: number[]) => arr.map(n => Math.log2(2));
const fib = (n: number) => (PHI ** n - (-PHI) ** -n) / Math.sqrt(5);

const createFibSequence = (start = 0, length = 5) =>
  new Array(length).fill(0).map((_, i) => fib(start + i + 2));

const fibonacci = [1, 2, 3, 5, 8];
const log2Fibonacci = log2(fibonacci);
const extendedFibSequence = [...fibonacci, ...createFibSequence(5, 5)];

Ao escrever nossas funções para retornar novas referências em favor da mutação de suas entradas, o array identificado pela declaração de fibonacci permanece inalterado e seu nome permanece uma fonte válida de identificação. Por fim, esse código é mais determinístico.

Navegando sobre as rachaduras

Com um pouco de disciplina, podemos agir de acordo com as referências como se elas fossem legíveis, mas o que impede que a mutação aconteça em outro lugar? O que nos impede de introduzir uma declaração para modificar o fibonacci em uma parte remota de nosso aplicativo?

fibonacci.push(4);

O ECMAScript 5 introduziu o Object.freeze(), que fornece alguma defesa contra a mutação de objetos:

'use strict';

const me = Object.freeze({
  name: 'James',
  age: 29,
  address: {
    // props from earlier example
  },
});

me.name = 'Rob'; // TypeError: 'name' is read-only
me.isTheBest = true; // TypeError: Object is not extensible

Infelizmente, proíbe apenas superficialmente a mutação de propriedades e, portanto, os objetos aninhados ainda podem ser alterados:

// No TypeErrors will be thrown
me.address.house = '666';
me.address.foo = 'bar';

Podemos chamar esse método em todos os objetos em uma árvore em particular, mas isso rapidamente se mostra pesado.

Como podemos aproveitar os recursos do TypeScript para imutabilidade em tempo de compilação?

Expressões literais aninhadamente congeladas com asserções const

No TypeScript, podemos usar asserções const, uma extensão das asserções de tipo, para congelar um tipo aninhado e para somente leitura de uma expressão literal:

const sitepoint = {
  name: 'SitePoint',
  isRegistered: true,
  address: {
    line1: 'PO Box 1115',
    town: 'Collingwood',
    region: 'VIC',
    postcode: '3066',
    country: 'Australia',
  },
  contentTags: ['JavaScript', 'HTML', 'CSS', 'React'],
} as const;

A anotação as const na expressão literal do objeto resulta na computação pelo TypeScript do tipo mais específico e somente leitura que ele conseguir:

{
  readonly name: 'SitePoint';
  readonly isRegistered: true;
  readonly address: {
    readonly line1: 'PO Box 1115';
    readonly town: 'Collingwood';
    readonly region: 'VIC';
    readonly postcode: '3066';
    readonly country: 'Australia';
  };
  readonly contentTags: readonly ['JavaScript', 'HTML', 'CSS', 'React'];
}

Em outras palavras:

  • As primitivas abertas serão reduzidas para tipos literais exatos (por exemplo, boolean => true)
  • Objeto literais terão suas propriedades modificadas com readonly
  • Arrays literais se tornarão tuplas readonly (por exemplo, string[] => ['foo', 'bar', 'baz'])

Tentar adicionar ou substituir quaisquer valores resultará no erro do compilador TypeScript:

sitepoint.isCharity = true; // isCharity does not exist on inferred type
sitepoint.address.country = 'United Kingdom'; // Cannot assign to 'country' because it is a read-only property

As asserções const resultam em tipos somente leitura, que intrinsecamente impedem a invocação de qualquer método de instância que mude um objeto:

sitepoint.contentTags.push('Pascal'); // Property 'push' does not exist on type 'readonly ["JavaScript", "HTML"...] 

Naturalmente, o único meio de usar objetos imutáveis ​​para refletir valores diferentes é criar novos objetos a partir deles:

const microsoft = {
  ...sitepoint,
  name: 'Microsoft',
} as const;

Parâmetros de função imutáveis

Como as asserções const são apenas um açúcar sintático para digitar uma declaração específica como um conjunto de propriedades somente leitura com valores literais, ainda é possível alterar as referências nos corpos das funções:

interface Person {
  name: string;
  address: {
    country: string;
  };
}

const me = {
  name: 'James',
  address: {
    country: 'United Kingdom',
  },
} as const;

const isJames = (person: Person) => {
  person.name = 'Sarah';
  return person.name === 'James';
};

console.log(isJames(me)); // false;
console.log(me.name); // 'Sarah';

Podemos resolver isso anotando o parâmetro person com Readonly<Person>, mas isso afeta apenas as propriedades no nível raiz do objeto:

const isJames = (person: Readonly<Person>) => {
  person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
  person.address.country = 'Australia'; // valid
  return person.name === 'James';
};

console.log(isJames(me)); // false
console.log(me.address.country); // 'Australia'

Não há tipos de utilitários embutidos para lidar com imutabilidade anihada, mas, como o TypeScript 3.7 apresenta melhor suporte para tipos recursivos ao adiar sua resolução, agora podemos expressar um tipo infinitamente recursivo para indicar propriedades como readonly em toda a profundidade de um objeto:

type Immutable<T> = {
  readonly [K in keyof T]: Immutable<T[K]>;
};

Se descrevêssemos o parâmetro person de isJames() como Immutable<Person>, o TypeScript também nos proibiria de alterar objetos aninhados:

const isJames = (person: Immutable<Person>) => {
  person.name = 'Sarah'; // Cannot assign to 'name' because it is a read-only property.
  person.address.country = 'Australia'; // Cannot assign to 'country' because it is a read-only property.
  return person.name === 'James';
};

Essa solução também funcionará para arrays profundamente aninhadas:

const hasCell = (cells: Immutable<string[][]>) => {
  cells[0][0] = 'no'; // Index signature in type 'readonly string[]' only permits reading.
};

Apesar de Immutable<T> ser um tipo definido manualmente, há discussões em andamento para introduzir o DeepReadonly no TypeScript, que possui uma semântica mais refinada.

Um exemplo do mundo real

O redux, uma biblioteca de gerenciamento de estado extremamente popular, exige que o estado seja tratado de forma imutável para determinar trivialmente se o estado precisa ser atualizado. Podemos ter interfaces de ação e estado de aplicativo semelhantes a:

interface Action {
  type: string;
  name: string;
  isComplete: boolean;
}

interface Todo {
  name: string;
  isComplete: boolean;
}

interface State {
  todos: Todo[];
}

Dado que nosso redutor deve retornar uma referência totalmente nova se o estado tiver sido atualizado, podemos digitar o argumento state com Immutable<State> para proibir quaisquer modificações:

const reducer = (
  state: Immutable<State>,
  action: Immutable<Action>,
): Immutable<State> => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [
          ...state.todos,
          {
            name: action.name,
            isComplete: false,
          },
        ],
      };

    default:
      return state;
  }
};

Benefícios adicionais da imutabilidade

Ao longo deste artigo, observamos como o tratamento de objetos imutável resulta em um código mais claro e mais determinístico. No entanto, existem algumas vantagens adicionais que vale a pena levantar.

Detectando alterações com o operador de comparação estrita

Em JavaScript, podemos usar o operador de comparação estrita (===) para determinar se dois objetos compartilham a mesma referência. Considere o nosso redutor no exemplo:

const reducer = (
  state: Immutable<State>,
  action: Immutable<TodoAction>,
): Immutable<State> => {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        // deeply merge TODOs
      };

    default:
      return state;
  }
};

Como criamos apenas uma nova referência se um estado alterado tiver sido calculado, podemos deduzir que a igualdade referencial estrita representa um objeto inalterado:

const action = {
  ...addTodoAction,
  type: 'NOOP',
};

const newState = reducer(state, action);
const hasStateChanged = state !== newState;

Detectar alterações por igualdade estrita de referência é mais simples do que comparar duas árvores de objetos aninhadas, o que normalmente envolve recursão.

Memoizando computações por referência

O tratamento de referências e expressões de objetos como um relacionamento individual (ou seja, uma única referência representa um conjunto exato de propriedades e valores), podemos memorizar computações caras por referência. Se quiséssemos adicionar um array contendo os primeiros 2000 números da sequência de Fibonacci, poderíamos usar uma função de ordem superior e um WeakMap para armazenar em cache o resultado de uma operação em uma referência específica:

const memoise = <TArg extends object, TResult>(func: Function) => {
  const results = new WeakMap<TArg, TResult>();

  return (arg: TArg) =>
    results.has(arg) ? results.get(arg) : results.set(arg, func(arg)).get(arg);
};

const sum = (numbers: number[]) => numbers.reduce((total, x) => total + x, 0);

const memoisedSum = memoise<number[], number>(sum);
const numbers = createFibSequence(0, 2000);

console.log(memoisedSum(numbers)); // Cache miss
console.log(memoisedSum(numbers)); // Cache hit

Imutabilidade não é uma bala de prata

Como todo paradigma de programação, a imutabilidade tem suas desvantagens:

  • A cópia de objetos aninhados com a sintaxe spread pode ser verboso, principalmente quando se está alterando apenas um único valor primitivo em uma árvore complexa.
  • Criar novas referências resultará em muitas alocações efêmeras de memória, que a coleta de lixo deve, conseqüentemente, dispor. Isso pode atrapalhar o encadeamento principal, embora coletores de lixo modernos como o Orinoco façam com paralelização.
  • Usar tipos imutáveis ​​e afirmações requer disciplina e consenso entre equipes. Regras de linters sendo discutidas como um meio de automatizar essas práticas, mas são propostas em estágio inicial.
  • Muitas APIs de primeira e de terceiros, como o DOM e as bibliotecas de análise, são modeladas a partir de mutação de objetos. Embora abstrações possam ajudar, a imutabilidade onipresente na Web é impossível.

Finalizando

O código carregado de mutação pode ter uma intenção opaca e resultar no comportamento inesperado do nosso software. A manipulação da sintaxe moderna do JavaScript pode incentivar os desenvolvedores a operar tipos imutáveis de referência - criando novos objetos a partir de referências existentes, ao invés de modificá-los diretamente - e complementá-los com construções TypeScript para obter imutabilidade em tempo de compilação. Certamente não é uma abordagem infalível, mas com alguma disciplina podemos escrever aplicativos extremamente robustos e previsíveis que, a longo prazo, facilitam nosso trabalho.

Créditos

Discussion

pic
Editor guide