Readonly e tipos imutáveis

1. Introdução à Imutabilidade em TypeScript

Imutabilidade é o princípio de que um objeto, uma vez criado, não pode ser modificado. Em vez de alterar um objeto existente, cria-se uma nova cópia com as mudanças desejadas. Esse conceito é fundamental para previsibilidade de código, debug simplificado e programas mais seguros em ambientes concorrentes.

No TypeScript, a imutabilidade opera em dois níveis distintos:

  • Tempo de compilação: O TypeScript impede que você escreva código que tente modificar propriedades marcadas como readonly. Isso gera erros durante a compilação.
  • Tempo de execução: O JavaScript não possui mecanismos nativos de imutabilidade para objetos comuns. Um readonly do TypeScript não impede que, em runtime, alguém modifique a propriedade usando JavaScript puro.

TypeScript oferece diversas ferramentas para trabalhar com imutabilidade: o modificador readonly, o tipo utilitário Readonly<T>, as const e a possibilidade de criar tipos recursivos para imutabilidade profunda.

2. O Modificador readonly em Propriedades

O modificador readonly pode ser aplicado diretamente em propriedades de interfaces, tipos de objeto e classes.

interface Usuario {
  readonly id: number;
  nome: string;
  email: string;
}

const usuario: Usuario = { id: 1, nome: "Ana", email: "ana@email.com" };
usuario.nome = "Maria"; // ✅ Permitido
usuario.id = 2; // ❌ Erro: Cannot assign to 'id' because it is a read-only property

Diferença entre readonly e const: const impede a reatribuição da variável, mas não torna o objeto imutável. readonly impede a modificação da propriedade específica.

const config = { url: "https://api.com" };
config.url = "https://novo.com"; // ✅ Permitido (objeto mutável)

interface Config {
  readonly url: string;
}
const config2: Config = { url: "https://api.com" };
config2.url = "https://novo.com"; // ❌ Erro

Em classes, propriedades readonly devem ser inicializadas na declaração ou no construtor:

class Circulo {
  readonly pi: number = 3.14159;
  readonly raio: number;

  constructor(raio: number) {
    this.raio = raio; // Inicialização permitida no construtor
  }
}

3. O Tipo Utilitário Readonly<T>

O tipo Readonly<T> é um tipo utilitário que transforma todas as propriedades de T em readonly.

type Configuracao = {
  host: string;
  porta: number;
  ssl: boolean;
};

type ConfiguracaoImutavel = Readonly<Configuracao>;

const config: ConfiguracaoImutavel = {
  host: "localhost",
  porta: 3000,
  ssl: true
};

config.porta = 4000; // ❌ Erro: Cannot assign to 'porta' because it is a read-only property

Exemplo prático: Proteger estado de aplicação contra modificações acidentais.

interface EstadoApp {
  usuarioLogado: boolean;
  dados: Record<string, unknown>;
  versao: number;
}

function congelarEstado(estado: EstadoApp): Readonly<EstadoApp> {
  return Object.freeze({ ...estado });
}

const estadoAtual = congelarEstado({
  usuarioLogado: true,
  dados: { nome: "João" },
  versao: 1
});

estadoAtual.versao = 2; // ❌ Erro em compilação

Limitação importante: Readonly<T> é superficial (shallow). Objetos aninhados ainda podem ser modificados.

interface Arvore {
  valor: number;
  filhos: Arvore[];
}

const arvore: Readonly<Arvore> = { valor: 1, filhos: [{ valor: 2, filhos: [] }] };
arvore.filhos.push({ valor: 3, filhos: [] }); // ✅ Permitido (array mutável)
arvore.filhos[0].valor = 10; // ✅ Permitido (objeto interno mutável)

4. Imutabilidade Profunda com Tipos Recursivos

Para imutabilidade profunda, podemos criar um tipo DeepReadonly<T> personalizado:

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends Record<string, unknown> | Array<unknown>
    ? DeepReadonly<T[P]>
    : T[P];
};

// Tratamento específico para arrays e tuplas
type DeepReadonlyArray<T extends readonly unknown[]> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
};

// Versão completa
type DeepReadonly<T> = T extends (...args: infer A) => infer R
  ? (...args: DeepReadonlyArray<A>) => DeepReadonly<R>
  : T extends object
  ? T extends readonly unknown[]
    ? DeepReadonlyArray<T>
    : { readonly [P in keyof T]: DeepReadonly<T[P]> }
  : T;

Exemplo de uso:

interface ConfigProfunda {
  servidor: {
    host: string;
    porta: number;
  };
  credenciais: {
    usuario: string;
    senha: string;
  };
  plugins: string[];
}

const config: DeepReadonly<ConfigProfunda> = {
  servidor: { host: "localhost", porta: 3000 },
  credenciais: { usuario: "admin", senha: "123" },
  plugins: ["logger", "auth"]
};

config.servidor.porta = 4000; // ❌ Erro
config.plugins.push("cache"); // ❌ Erro (array readonly profundo)

Desafios com tipos complexos: Union types e intersection types podem exigir tratamento especial, e tipos recursivos podem impactar a performance do compilador em estruturas muito profundas.

5. readonly em Arrays e Tuplas

TypeScript oferece três formas de declarar arrays readonly:

// Forma 1: readonly T[]
const arr1: readonly number[] = [1, 2, 3];

// Forma 2: ReadonlyArray<T>
const arr2: ReadonlyArray<number> = [1, 2, 3];

// Forma 3: Readonly aplicado a Array
const arr3: Readonly<number[]> = [1, 2, 3];

Métodos disponíveis vs. indisponíveis:

const numeros: readonly number[] = [1, 2, 3];

numeros[0];        // ✅ Acesso permitido
numeros.length;    // ✅ Propriedade length
numeros.map(n => n * 2);  // ✅ Métodos que retornam novo array
numeros.filter(n => n > 1); // ✅
numeros.concat([4]); // ✅

numeros.push(4);   // ❌ Erro: push não existe em readonly array
numeros.pop();     // ❌ Erro
numeros[0] = 10;   // ❌ Erro: index assignment não permitido

Tuplas readonly:

type Par = readonly [string, number];
const par: Par = ["idade", 30];

par[0] = "altura"; // ❌ Erro
par.push(40);      // ❌ Erro

// Tuplas com spread
type Lista = readonly [number, ...string[]];
const lista: Lista = [1, "a", "b", "c"];

6. Imutabilidade com as const

O operador as const (const assertions) cria tipos literais e torna todas as propriedades profundamente readonly:

const config = {
  url: "https://api.com",
  timeout: 5000,
  headers: {
    "Content-Type": "application/json"
  }
} as const;

// Tipo inferido:
// {
//   readonly url: "https://api.com";
//   readonly timeout: 5000;
//   readonly headers: {
//     readonly "Content-Type": "application/json";
//   };
// }

config.url = "https://novo.com"; // ❌ Erro
config.headers["Content-Type"] = "text/html"; // ❌ Erro (profundamente readonly)

Comparação: as const vs. Readonly<T>:

// as const: tipos literais e imutabilidade profunda
const obj1 = { nome: "Ana", idade: 30 } as const;
// Tipo: { readonly nome: "Ana"; readonly idade: 30 }

// Readonly<T>: mantém tipos amplos, imutabilidade superficial
const obj2: Readonly<{ nome: string; idade: number }> = { nome: "Ana", idade: 30 };
// Tipo: { readonly nome: string; readonly idade: number }

as const é ideal para constantes e literais, enquanto Readonly<T> é mais flexível para tipos genéricos.

7. Boas Práticas e Padrões de Uso

Usar readonly em parâmetros de funções para garantir que a função não modifique os dados recebidos:

function processarDados(dados: readonly number[]): number {
  // dados.push(10); // ❌ Erro em compilação
  return dados.reduce((acc, val) => acc + val, 0);
}

Imutabilidade em APIs e contratos de interface:

interface Repositorio<T> {
  readonly itens: readonly T[];
  obterPorId(id: string): T | undefined;
  adicionar(item: T): Repositorio<T>; // Retorna nova instância
}

class RepositorioMemo<T> implements Repositorio<T> {
  readonly itens: readonly T[];

  constructor(itens: T[]) {
    this.itens = Object.freeze([...itens]);
  }

  obterPorId(id: string): T | undefined {
    return this.itens.find(item => (item as any).id === id);
  }

  adicionar(item: T): Repositorio<T> {
    return new RepositorioMemo([...this.itens, item]);
  }
}

Combinando readonly com generics e constraints:

function clonar<T extends Record<string, unknown>>(obj: T): Readonly<T> {
  return Object.freeze({ ...obj });
}

function mesclar<T extends readonly unknown[], U extends readonly unknown[]>(
  arr1: T,
  arr2: U
): readonly [...T, ...U] {
  return [...arr1, ...arr2];
}

8. Limitações e Considerações Finais

Imutabilidade em tempo de compilação não é garantia em runtime. O TypeScript não gera código adicional para proteger objetos:

interface Usuario {
  readonly id: number;
}

const usuario: Usuario = { id: 1 };

// Em runtime, isso funciona (embora TypeScript acuse erro)
(usuario as any).id = 2;

Para proteção em runtime, combine readonly com Object.freeze():

function criarImutavel<T extends object>(obj: T): Readonly<T> {
  return Object.freeze({ ...obj });
}

Performance: Tipos recursivos como DeepReadonly<T> podem aumentar o tempo de compilação em estruturas muito complexas. Use com moderação e considere o trade-off entre segurança de tipos e performance.

Alternativas: Para imutabilidade em runtime com melhor suporte, bibliotecas como Immer permitem trabalhar com estado imutável de forma produtiva, usando um "draft" mutável que é convertido em estado imutável no final.

A imutabilidade em TypeScript é uma ferramenta poderosa para escrever código mais seguro e previsível. Dominar readonly, Readonly<T>, as const e tipos recursivos permite criar contratos claros e evitar bugs relacionados a mutações indesejadas, especialmente em aplicações complexas com estado compartilhado.

Referências