search

TypeScript: Implementando Readonly Recursivo

TypeScript: Implementando Readonly Recursivo

TypeScript: Implementando Readonly Recursivo

A palavra-chave TypeScript Readonly costuma aparecer quando equipes querem reduzir bugs causados por mutações acidentais em objetos: alguém altera uma propriedade “sem querer”, um estado compartilhado muda, e o efeito colateral aparece longe do ponto de alteração.

O TypeScript oferece Readonly<T> como utilitário nativo, mas ele é superficial (shallow): torna somente as propriedades do primeiro nível como readonly. Em estruturas reais — objetos aninhados, arrays de objetos, configurações profundas, payloads de API — isso não é suficiente.

Este artigo mostra, de forma prática, como implementar um Readonly recursivo (frequentemente chamado de DeepReadonly) em TypeScript, quais são os detalhes importantes (arrays, funções, tuplas, Map/Set, tipos especiais) e como aplicar sem comprometer a ergonomia do código.


1) O que Readonly<T> faz (e o que não faz)

O tipo utilitário nativo:

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

Ele marca as propriedades como readonly, mas não entra recursivamente em objetos internos.

Exemplo:

type Config = {
  api: {
    baseUrl: string;
  };
};

const cfg: Readonly<Config> = {
  api: { baseUrl: "https://exemplo.com" }
};

// Erro (ok): não posso reatribuir o topo
// cfg.api = { baseUrl: "x" };

// Permite (problema): posso mutar internamente
cfg.api.baseUrl = "https://alterado.com";

Esse cenário é comum: você “protege” o objeto, mas ainda permite mutações em níveis internos.


2) O objetivo do Readonly recursivo

Um Readonly recursivo deve impedir:

  • reatribuição de qualquer propriedade em qualquer nível;
  • mutações em arrays (push, pop, etc.), transformando-os em readonly arrays;
  • mutações dentro de elementos do array (se forem objetos), também recursivamente.

O comportamento desejado:

type DeepReadonlyConfig = DeepReadonly<Config>;

const cfg: DeepReadonlyConfig = {
  api: { baseUrl: "https://exemplo.com" }
};

// Deve falhar
// cfg.api.baseUrl = "https://alterado.com";

3) Implementação base: DeepReadonly<T> para objetos

A base do tipo recursivo costuma ser uma combinação de:

  • conditional types (para decidir se é objeto, array, função etc.);
  • mapped types (para aplicar readonly a propriedades).

Implementação inicial (didática):

type DeepReadonly<T> =
  T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

Isso já resolve muitos casos, mas tem armadilhas:

  1. Em TypeScript, object inclui arrays e funções (ambos são objetos).
  2. Para funções, normalmente você não quer transformar “propriedades internas” da função; quer manter a assinatura.
  3. Arrays devem virar ReadonlyArray, e seus elementos devem ser aplicados recursivamente.

4) Lidando corretamente com funções e arrays

Uma abordagem mais robusta:

type DeepReadonly<T> =
  // Funções: manter assinatura (não “congelar” parâmetros/retorno por aqui)
  T extends (...args: any[]) => any
    ? T
    // Arrays e tuplas: tornar readonly e aplicar recursão no elemento
    : T extends readonly (infer U)[]
      ? ReadonlyArray<DeepReadonly<U>>
      // Objetos: mapear propriedades com readonly
      : T extends object
        ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
        : T;

Pontos importantes:

  • T extends readonly (infer U)[] captura tanto T[] quanto readonly T[] e tuplas ([A, B] também se encaixa).
  • ReadonlyArray<...> garante que métodos mutáveis não estejam disponíveis.
  • Funções são preservadas como estão, o que evita efeitos colaterais estranhos ao “mapear” propriedades de um callable.

Exemplo com array:

type State = {
  users: Array<{ id: string; profile: { name: string } }>;
};

const s: DeepReadonly<State> = {
  users: [{ id: "1", profile: { name: "Ana" } }]
};

// Deve falhar: push em readonly array
// s.users.push({ id: "2", profile: { name: "Bia" } });

// Deve falhar: mutação profunda
// s.users[0].profile.name = "Alterado";

5) Tuplas e literais: o que esperar

Tuplas são arrays com tamanho fixo. O tipo acima converte tuplas em ReadonlyArray, o que pode “perder” parte da informação de tupla (dependendo do caso). Se você precisa preservar tuplas com precisão, um refinamento pode ser necessário.

Para muitos projetos, a conversão para ReadonlyArray é aceitável. Se você depende de índices fixos, vale testar no seu código e, se necessário, ajustar com técnicas mais avançadas de inferência para tuplas.


6) Map, Set, Date e outros “objetos especiais”

T extends object pega tudo que é objeto — incluindo Date, Map, Set, RegExp etc. Em termos de imutabilidade, isso é delicado:

  • Date é mutável (setFullYear, etc.).
  • Map/Set são mutáveis (set, add, delete).

Um DeepReadonly puramente estrutural vai transformar propriedades, mas não elimina métodos mutáveis nem impede que sejam chamados (porque os métodos fazem parte do tipo). Para uma proteção mais realista, você pode mapear esses tipos para versões readonly:

  • ReadonlyMap<K, V>
  • ReadonlySet<T>

Exemplo de extensão:

type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T
    : T extends Map<infer K, infer V>
      ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
      : T extends Set<infer U>
        ? ReadonlySet<DeepReadonly<U>>
        : T extends readonly (infer U)[]
          ? ReadonlyArray<DeepReadonly<U>>
          : T extends object
            ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
            : T;

Sobre Date: não existe um ReadonlyDate nativo. Em ambientes críticos, a recomendação costuma ser evitar Date mutável no domínio (preferir string ISO, number timestamp, ou bibliotecas/abstrações imutáveis) ou tratar Date como atômico (não recursivo) para não criar uma falsa sensação de segurança.

Você pode tratá-lo como “primitivo”:

type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T
    : T extends Date
      ? T
      : T extends Map<infer K, infer V>
        ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
        : T extends Set<infer U>
          ? ReadonlySet<DeepReadonly<U>>
          : T extends readonly (infer U)[]
            ? ReadonlyArray<DeepReadonly<U>>
            : T extends object
              ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
              : T;

7) Segurança e confiabilidade: DeepReadonly não congela em runtime

Aqui entra um ponto relevante de Cyber Segurança e robustez: tipos do TypeScript atuam apenas em tempo de compilação. Se um dado vier de fora (API, usuário, arquivo, storage), ele pode ser mutado em runtime por qualquer parte do código JavaScript.

Ou seja:

  • DeepReadonly<T> impede mutações no seu código TypeScript (quando você respeita o tipo).
  • Ele não impede mutação real se alguém tiver uma referência e alterar em runtime.
  • Ele não valida dados de entrada (para isso você precisa de validação/parse em runtime).

Se seu objetivo inclui proteção em runtime (por exemplo, evitar que um objeto de configuração seja alterado), você pode combinar com Object.freeze. O problema: Object.freeze é superficial, então você precisará de um “freeze profundo”.

Exemplo didático de freeze profundo:

function deepFreeze<T>(obj: T): DeepReadonly<T> {
  if (obj && typeof obj === "object") {
    Object.freeze(obj);
    for (const key of Object.keys(obj as object)) {
      const value = (obj as any)[key];
      deepFreeze(value);
    }
  }
  return obj as DeepReadonly<T>;
}

Observações:

  • Isso tem limitações (ciclos de referência quebram; Map/Set não “congelam” bem; performance em objetos grandes).
  • Ainda assim, é útil para configs e estruturas relativamente pequenas e controladas.

Em cenários de segurança, a estratégia típica é: validação/normalização na entrada + tipos para reduzir bugs internos + imutabilidade onde fizer sentido (estado e configurações).


8) Aplicação prática passo a passo

Passo 1: criar o tipo em um arquivo utilitário

Crie types/deep-readonly.ts:

export type DeepReadonly<T> =
  T extends (...args: any[]) => any
    ? T
    : T extends Date
      ? T
      : T extends Map<infer K, infer V>
        ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
        : T extends Set<infer U>
          ? ReadonlySet<DeepReadonly<U>>
          : T extends readonly (infer U)[]
            ? ReadonlyArray<DeepReadonly<U>>
            : T extends object
              ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
              : T;

Passo 2: aplicar em tipos de retorno e contratos

Exemplo com “config”:

type AppConfig = {
  endpoints: {
    auth: string;
  };
  flags: string[];
};

function loadConfig(): DeepReadonly<AppConfig> {
  const cfg: AppConfig = {
    endpoints: { auth: "https://api.exemplo.com/auth" },
    flags: ["a", "b"]
  };

  // opcional: deepFreeze(cfg) para runtime
  return cfg;
}

Passo 3: usar para reduzir mutações em estados compartilhados

Em contextos como store/estado:

type StoreState = {
  session: { token: string; user: { id: string } } | null;
};

type ReadonlyState = DeepReadonly<StoreState>;

Isso força alterações a acontecerem via criação de novos objetos (padrão comum em arquiteturas previsíveis).


9) Limitações e boas práticas

  • Recursão profunda pode impactar performance do type-checker em tipos enormes. Use com critério em fronteiras (config, DTOs, state), não necessariamente em tudo.
  • Não substitui validação de runtime. Para dados externos, combine com schemas (ex.: Zod, Valibot, Yup) ou validação manual.
  • Cuidado com tipos especiais: Map/Set/Date e classes com métodos mutáveis podem exigir tratamento específico.
  • Readonly não é segurança: é uma ferramenta para reduzir bugs e inconsistências; não protege contra código malicioso que rode no mesmo runtime.

Conclusão

TypeScript Readonly é um ótimo ponto de partida, mas Readonly<T> é superficial. Ao implementar um DeepReadonly<T> com conditional types e mapped types, você consegue impor imutabilidade em estruturas aninhadas, incluindo arrays e coleções como Map e Set, com ganhos reais de previsibilidade e manutenção.

O ponto central é entender o escopo: DeepReadonly melhora o contrato do código em tempo de compilação; se você também precisa de garantias em runtime, considere combinar com congelamento profundo e validação de entrada, especialmente em áreas sensíveis e de maior impacto operacional.

Tags: Backend
Compartilhar este artigo: