Readonly deep: imutabilidade profunda

1. Introdução à Imutabilidade e Readonly Nativo

Imutabilidade é um dos pilares da programação funcional e uma prática cada vez mais adotada em aplicações TypeScript modernas. Quando um objeto é imutável, seu estado não pode ser alterado após a criação — qualquer "modificação" gera um novo objeto. Isso elimina efeitos colaterais, facilita o rastreamento de mudanças e torna o código mais previsível, especialmente em aplicações com estado global como Redux ou Zustand.

O TypeScript oferece o tipo utilitário Readonly<T> para marcar propriedades como somente leitura:

type User = {
  name: string;
  address: {
    city: string;
    zip: string;
  };
};

const user: Readonly<User> = {
  name: "Alice",
  address: { city: "SP", zip: "01000" }
};

user.name = "Bob"; // Erro: não pode atribuir a 'name' porque é readonly

Porém, Readonly é superficial. Veja o problema:

user.address.city = "RJ"; // ✅ Compila! Mutação ocorre sem erros

O TypeScript não reclama porque Readonly apenas torna as propriedades do primeiro nível imutáveis. Propriedades aninhadas permanecem mutáveis. Essa limitação é a porta de entrada para bugs silenciosos.

2. O Problema da Imutabilidade Superficial

Em cenários reais, a mutação acidental em objetos aninhados é comum. Considere um estado de aplicação:

type AppState = {
  user: {
    profile: { name: string; email: string };
    preferences: { theme: "light" | "dark" };
  };
  ui: { sidebarOpen: boolean };
};

function updateTheme(state: Readonly<AppState>, theme: "light" | "dark") {
  // Intenção: retornar novo estado
  state.user.preferences.theme = theme; // ❌ Mutação direta, sem erro de compilação
  return state;
}

Aqui, Readonly falha em proteger o estado. O contrato de imutabilidade é quebrado sem que o TypeScript aponte o problema. As consequências incluem:

  • Bugs intermitentes: componentes React podem não re-renderizar corretamente
  • Comportamento imprevisível: funções puras tornam-se impuras
  • Dificuldade de debug: a origem da mutação é difícil de rastrear

O que realmente precisamos é de um tipo ReadonlyDeep que torne todas as propriedades, em qualquer profundidade, imutáveis.

3. Implementando ReadonlyDeep com Tipos Recursivos

A solução está nos tipos condicionais recursivos do TypeScript. Vamos construir nosso próprio ReadonlyDeep:

type ReadonlyDeep<T> = T extends (infer U)[]
  ? ReadonlyArray<ReadonlyDeep<U>>
  : T extends Function
    ? T
    : T extends object
      ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }
      : T;

Vamos analisar cada parte:

  1. Arrays: se T é um array (T extends (infer U)[]), aplicamos ReadonlyArray recursivamente nos elementos
  2. Funções: funções não devem ser transformadas — mantemos o tipo original
  3. Objetos: para qualquer objeto, tornamos cada propriedade readonly e aplicamos recursão
  4. Primitivos: strings, numbers, booleans etc. são mantidos como estão

Exemplo de uso:

type DeepUser = ReadonlyDeep<User>;

const user: DeepUser = {
  name: "Alice",
  address: { city: "SP", zip: "01000" }
};

user.address.city = "RJ"; // ❌ Erro: não pode atribuir a 'city' porque é readonly

Para arrays e tuplas:

type Data = { items: { id: number; value: string }[] };
type DeepData = ReadonlyDeep<Data>;

const data: DeepData = { items: [{ id: 1, value: "a" }] };
data.items[0].value = "b"; // ❌ Erro: readonly
data.items.push({ id: 2, value: "c" }); // ❌ Erro: 'push' não existe em ReadonlyArray

4. Lidando com Casos Complexos e Limitações

Union types e discriminated unions

ReadonlyDeep lida bem com union types, aplicando recursão em cada membro:

type Result = { status: "success"; data: string } | { status: "error"; message: string };
type DeepResult = ReadonlyDeep<Result>;
// Funciona corretamente para ambos os casos

Tipos opcionais e undefined

Propriedades opcionais são preservadas:

type Config = { timeout?: number; headers: Record<string, string> };
type DeepConfig = ReadonlyDeep<Config>;
// timeout continua opcional, headers é profundamente readonly

Limitações conhecidas

  • Referências circulares: tipos como type TreeNode = { value: number; children: TreeNode[] } podem causar erro de recursão infinita
  • any e unknown: ReadonlyDeep<any> resolve para any, perdendo a proteção
  • Map, Set, Promise: objetos nativos com métodos mutáveis não são cobertos — seria necessário tratamento específico

5. Integração com Bibliotecas e Padrões de Projeto

Usando com Immer

O Immer permite "mutar" estados imutáveis com draft. Combinado com ReadonlyDeep, temos segurança em tempo de compilação e runtime:

import { produce } from "immer";

type State = ReadonlyDeep<{ user: { name: string; score: number } }>;

const baseState: State = { user: { name: "Alice", score: 0 } };

const nextState = produce(baseState, (draft) => {
  draft.user.score = 10; // ✅ Permitido pelo Immer, mas draft é ReadonlyDeep
});

Padrão Redux/Zustand

Em Redux, reducers devem ser puros. ReadonlyDeep garante que nenhum reducer muta o estado:

type AppState = ReadonlyDeep<{ todos: { id: number; text: string; done: boolean }[] }>;

function todosReducer(state: AppState, action: { type: "TOGGLE"; id: number }): AppState {
  // Sem ReadonlyDeep, poderíamos fazer state.todos.find(t => t.id === action.id)!.done = true
  return {
    ...state,
    todos: state.todos.map(t => t.id === action.id ? { ...t, done: !t.done } : t)
  };
}

Combinação com as const e satisfies

const config = {
  api: { url: "https://api.example.com", retries: 3 },
  debug: false
} as const satisfies ReadonlyDeep<typeof config>;
// config é profundamente readonly e com tipos literais

6. Performance, Compilação e Boas Práticas

Impacto na compilação

Tipos recursivos profundos podem aumentar o tempo de compilação. Para objetos com mais de 5-6 níveis de aninhamento, considere limitar a profundidade:

type ReadonlyDeepLimited<T, Depth extends number = 3> = Depth extends 0
  ? T
  : T extends (infer U)[]
    ? ReadonlyArray<ReadonlyDeepLimited<U, Subtract<Depth, 1>>>
    : T extends object
      ? { readonly [K in keyof T]: ReadonlyDeepLimited<T[K], Subtract<Depth, 1>> }
      : T;

Boas práticas

  1. Use Readonly para objetos simples (1 nível) — é mais rápido e legível
  2. Reserve ReadonlyDeep para:
  3. Estados globais de aplicação
  4. Dados vindos de APIs externas
  5. Configurações que não devem ser alteradas
  6. Evite em:
  7. Tipos com referências circulares
  8. Objetos com mais de 10 níveis de profundidade
  9. Props de componentes React (a menos que necessário)

7. Ferramentas e Alternativas

Bibliotecas populares

  • type-fest: oferece ReadonlyDeep maduro e testado
    typescript import type { ReadonlyDeep } from 'type-fest';

  • ts-essentials: alternativa com DeepReadonlyObject e suporte a Map/Set

Comparação com runtime

Object.freeze congela objetos em runtime, mas não oferece proteção em tempo de compilação:

const frozen = Object.freeze({ user: { name: "Alice" } });
frozen.user.name = "Bob"; // ✅ Compila, mas falha silenciosamente em strict mode

A combinação ideal: ReadonlyDeep para proteção em compilação + Object.freeze para proteção em runtime.

Alternativas futuras

Há discussões na comunidade TypeScript (TC39/TypeScript) sobre adicionar ReadonlyDeep como tipo nativo. Enquanto isso não acontece, as soluções manuais ou de bibliotecas são suficientes.

8. Conclusão e Próximos Passos

A imutabilidade profunda com ReadonlyDeep transforma a forma como escrevemos TypeScript. Ela elimina uma classe inteira de bugs relacionados a mutação acidental, torna o código mais declarativo e facilita a adoção de padrões como Redux e Immer.

Checklist para implementação

  • [ ] Avalie se seu projeto precisa de imutabilidade profunda
  • [ ] Escolha entre implementação manual ou biblioteca (type-fest)
  • [ ] Defina tipos globais com ReadonlyDeep para estados
  • [ ] Combine com Object.freeze em pontos críticos
  • [ ] Monitore o tempo de compilação e ajuste a profundidade máxima

Próximos passos

Explore temas vizinhos como Option types (para lidar com nulabilidade) e Phantom types (para segurança de tipos em runtime). A combinação dessas técnicas leva a um sistema de tipos que previne bugs antes mesmo de o código ser executado.

Referências