Pular para o conteúdo
TypeScript

DeepReadonly e DeepPartial em TypeScript

Readonly e Partial do TypeScript só funcionam no primeiro nível. Pra proteger objetos aninhados de verdade, você precisa de tipos recursivos.

Por que isso é importante

DeepReadonly e DeepPartial em TypeScript. Readonly e Partial do TypeScript só funcionam no primeiro nível. Pra proteger objetos aninhados de verdade, você precisa de tipos recursivos.

O Limite do Readonly Nativo

Vamos começar pelo problema real. Você tem uma config de servidor complexa e quer proteger ela contra mutações acidentais:

interface ServerConfig {
  host: string;
  port: number;
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  cache: {
    enabled: boolean;
    ttl: number;
  };
}

const config: Readonly<ServerConfig> = {
  host: 'localhost',
  port: 3000,
  database: {
    host: 'db.example.com',
    port: 5432,
    credentials: {
      username: 'admin',
      password: 'secret'
    }
  },
  cache: {
    enabled: true,
    ttl: 3600
  }
};

// ❌ TypeScript bloqueia isso - PRIMEIRA camada protegida
config.port = 4000; // Error: Cannot assign to 'port'

// ✅ Mas isso passa tranquilo - nenhuma proteção no nested
config.database.port = 5433; // Sem erro! 😱
config.database.credentials.password = 'hacked'; // Sem erro! 😱
config.cache.ttl = 0; // Sem erro! 😱

Percebe o problema? O Readonly nativo só congela as propriedades de primeiro nível. Qualquer coisa dentro de objetos aninhados fica totalmente vulnerável. É como trancar a porta da frente mas deixar todas as janelas abertas.

Pior ainda: o compilador não te avisa. O código compila feliz, você acha que tá protegido, e só descobre o bug em produção quando alguém muda uma config crítica sem querer. Já vi deploy cair por causa disso - alguém mudou database.host em um lugar errado e derrubou o sistema inteiro.

Mesma história com Partial. Você marca uma interface como Partial pra permitir updates parciais, mas só funciona no primeiro nível. Se você precisa fazer um update em database.credentials.password, o TypeScript não sabe que isso deve ser opcional também.

Construindo DeepReadonly do Zero

Agora vem a parte interessante. Vamos construir um tipo recursivo que aplica readonly em TODAS as camadas, não importa quão profundo seja o objeto. A técnica usa mapped types + conditional types + recursão.

Primeiro, o tipo básico sem tratar casos especiais:

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

Simples assim. Pegamos cada propriedade K do tipo T, marcamos como readonly, e aplicamos DeepReadonly recursivamente no valor T[K]. Se o valor for um objeto, o tipo chama ele mesmo de novo. Se for um primitivo (string, number, boolean), a recursão para naturalmente.

Mas esse tipo básico tem problemas. Ele tenta aplicar DeepReadonly em funções, o que não faz sentido. E trata arrays de forma estranha. Vamos refinar:

type DeepReadonly<T> = T extends (...args: any[]) => any
  ? T // Se for função, retorna ela sem mudanças
  : T extends any[]
  ? ReadonlyArray<DeepReadonly<T[number]>> // Se for array, aplica no tipo dos elementos
  : T extends object
  ? { readonly [K in keyof T]: DeepReadonly<T[K]> } // Se for objeto, recursão
  : T; // Primitivos retornam sem mudanças

// Agora sim funciona corretamente
const config: DeepReadonly<ServerConfig> = {
  host: 'localhost',
  port: 3000,
  database: {
    host: 'db.example.com',
    port: 5432,
    credentials: {
      username: 'admin',
      password: 'secret'
    }
  },
  cache: {
    enabled: true,
    ttl: 3600
  }
};

// ❌ TUDO bloqueado agora!
config.port = 4000; // Error
config.database.port = 5433; // Error ✅
config.database.credentials.password = 'hacked'; // Error ✅
config.cache.ttl = 0; // Error ✅

A estrutura do tipo usa conditional types pra testar o que T é em cada chamada recursiva. Primeiro checa se é função (retorna sem mudar), depois se é array (usa ReadonlyArray), depois se é objeto (aplica recursão), e por fim primitivos (retorna direto).

Detalhe importante: pra arrays, usamos T[number] que é o tipo dos elementos do array. Então ReadonlyArray> significa 'array readonly onde cada elemento também é deeply readonly'. Se você tem um array de objetos, cada objeto dentro fica protegido também.

Exemplo com arrays:

interface User {
  id: number;
  profile: {
    name: string;
    settings: {
      theme: string;
      notifications: boolean;
    };
  };
  posts: Array<{
    id: number;
    content: string;
  }>;
}

const user: DeepReadonly<User> = {
  id: 1,
  profile: {
    name: 'João',
    settings: {
      theme: 'dark',
      notifications: true
    }
  },
  posts: [
    { id: 1, content: 'Post 1' },
    { id: 2, content: 'Post 2' }
  ]
};

// ❌ Nada disso funciona - proteção completa
user.id = 2; // Error
user.profile.name = 'Maria'; // Error
user.profile.settings.theme = 'light'; // Error
user.posts.push({ id: 3, content: 'Post 3' }); // Error
user.posts[0].content = 'Editado'; // Error

Veja como o tipo protege até elementos dentro do array. user.posts[0].content não pode ser modificado porque DeepReadonly desceu recursivamente até o conteúdo de cada post. Isso é poder real.

Construindo DeepPartial com a Mesma Lógica

DeepPartial segue a mesma estratégia, mas inverte o objetivo. Em vez de proteger contra mutação, queremos permitir updates parciais em qualquer nível da estrutura. Super útil pra funções de update onde você só passa os campos que mudaram.

type DeepPartial<T> = T extends (...args: any[]) => any
  ? T // Funções não mudam
  : T extends any[]
  ? Array<DeepPartial<T[number]>> // Arrays com elementos parciais
  : T extends object
  ? { [K in keyof T]?: DeepPartial<T[K]> } // Objeto com props opcionais
  : T; // Primitivos sem mudança

// Função de update que aceita mudanças parciais em qualquer nível
function updateConfig(
  current: ServerConfig,
  updates: DeepPartial<ServerConfig>
): ServerConfig {
  return {
    ...current,
    ...updates,
    database: {
      ...current.database,
      ...updates.database,
      credentials: {
        ...current.database.credentials,
        ...updates.database?.credentials
      }
    },
    cache: {
      ...current.cache,
      ...updates.cache
    }
  };
}

// ✅ Todas essas chamadas são type-safe
updateConfig(config, {
  port: 4000 // Só muda port
});

updateConfig(config, {
  database: {
    port: 5433 // Só muda database.port
  }
});

updateConfig(config, {
  database: {
    credentials: {
      password: 'new-password' // Só muda password aninhado
    }
  }
});

updateConfig(config, {
  cache: {
    ttl: 7200 // Só muda cache.ttl
  }
});

A diferença crucial tá no mapped type: [K in keyof T]? com o ? depois da chave. Isso torna cada propriedade opcional, não importa o nível. E aplicamos DeepPartial recursivamente no valor, então propriedades aninhadas também ficam opcionais.

Resultado: você pode passar um objeto com QUALQUER combinação de propriedades, em QUALQUER profundidade, e o TypeScript valida tudo corretamente. Mudou só database.credentials.password? Funciona. Mudou port + cache.ttl? Funciona. Mudou só cache.enabled? Funciona.

Isso é especialmente poderoso em aplicações Redux ou Zustand, onde você faz updates parciais no estado constantemente. Sem DeepPartial, você seria obrigado a passar o objeto completo sempre, ou perderia type-safety nos nested objects.

Exemplo com estado Redux:

interface AppState {
  user: {
    id: number;
    profile: {
      name: string;
      avatar: string;
      preferences: {
        theme: string;
        language: string;
        notifications: {
          email: boolean;
          push: boolean;
        };
      };
    };
  };
  ui: {
    sidebar: {
      collapsed: boolean;
      pinned: boolean;
    };
    modals: {
      settings: boolean;
      help: boolean;
    };
  };
}

// Action creator com DeepPartial
function updateState(updates: DeepPartial<AppState>) {
  // Merge logic aqui
}

// ✅ Qualquer combinação funciona
updateState({
  user: {
    profile: {
      preferences: {
        notifications: {
          push: false // Só desliga notificações push
        }
      }
    }
  }
});

updateState({
  ui: {
    sidebar: {
      collapsed: true // Só colapsa sidebar
    }
  }
});

// ❌ Mas typos ainda são bloqueados
updateState({
  user: {
    profile: {
      preferences: {
        notifications: {
          sms: true // Error: 'sms' não existe
        }
      }
    }
  }
});

Percebe a vantagem? Você mantém type-safety completo mesmo fazendo updates micro-cirúrgicos no estado. Muda uma propriedade que tá 5 níveis deep? TypeScript valida. Tenta acessar uma propriedade que não existe? Erro de compilação. É o melhor dos dois mundos.

DeepRequired: O Inverso do Partial

Às vezes você precisa do contrário de DeepPartial. Pega uma interface onde várias propriedades são opcionais, e você quer garantir que TUDO foi preenchido antes de passar pra frente. DeepRequired faz exatamente isso.

type DeepRequired<T> = T extends (...args: any[]) => any
  ? T
  : T extends any[]
  ? Array<DeepRequired<T[number]>>
  : T extends object
  ? { [K in keyof T]-?: DeepRequired<T[K]> } // -? remove o opcional
  : T;

interface FormData {
  personal?: {
    name?: string;
    email?: string;
    address?: {
      street?: string;
      city?: string;
      country?: string;
    };
  };
  payment?: {
    method?: string;
    card?: {
      number?: string;
      cvv?: string;
    };
  };
}

// Validação que exige tudo preenchido
function submitForm(data: DeepRequired<FormData>) {
  // Aqui você TEM CERTEZA que todas as props existem
  console.log(data.personal.name); // Sem undefined!
  console.log(data.personal.address.city); // Sem undefined!
  console.log(data.payment.card.cvv); // Sem undefined!
}

const validData: DeepRequired<FormData> = {
  personal: {
    name: 'João',
    email: 'joao@example.com',
    address: {
      street: 'Rua A',
      city: 'São Paulo',
      country: 'Brasil'
    }
  },
  payment: {
    method: 'credit',
    card: {
      number: '1234',
      cvv: '123'
    }
  }
};

// ❌ Dados incompletos não compilam
const invalidData: DeepRequired<FormData> = {
  personal: {
    name: 'João'
    // Error: faltam email e address
  }
};

O truque tá no -? que remove o modificador opcional. Enquanto ? adiciona optional, -? remove. Então [K in keyof T]-? pega propriedades opcionais e torna elas required de novo.

Uso comum: formulários multi-step. Nos primeiros steps, você permite dados parciais (com DeepPartial). No último step, antes de submeter, você valida que TUDO foi preenchido (com DeepRequired). O TypeScript garante que você não esqueceu nenhum campo.

Outro caso: APIs que retornam dados parciais em list (só id e name), mas full details quando você busca por id. Você pode tipar a lista como DeepPartial e o detalhe como DeepRequired. Fica crystal clear qual endpoint retorna o quê.

Casos Práticos no Mundo Real

Vamos ver aplicações reais onde tipos recursivos salvam o dia. Não é teoria acadêmica - é código que roda em produção todos os dias.

Config Objects: Qualquer aplicação com config complexa precisa de DeepReadonly. Você quer expor a config pra toda aplicação, mas sem risco de alguém mudar ela por acidente. Carregou do .env uma vez no boot, sealed com DeepReadonly, e pronto. Ninguém consegue quebrar.

// config.ts
import { z } from 'zod';

const configSchema = z.object({
  app: z.object({
    name: z.string(),
    version: z.string(),
    environment: z.enum(['dev', 'staging', 'prod'])
  }),
  server: z.object({
    port: z.number(),
    host: z.string(),
    ssl: z.object({
      enabled: z.boolean(),
      cert: z.string().optional(),
      key: z.string().optional()
    })
  }),
  database: z.object({
    host: z.string(),
    port: z.number(),
    name: z.string(),
    pool: z.object({
      min: z.number(),
      max: z.number(),
      idle: z.number()
    })
  })
});

type Config = z.infer<typeof configSchema>;

// Singleton config sealed após carregar
class ConfigService {
  private static _instance: DeepReadonly<Config> | null = null;

  static load(): DeepReadonly<Config> {
    if (!this._instance) {
      const rawConfig = configSchema.parse({
        app: {
          name: process.env.APP_NAME,
          version: process.env.APP_VERSION,
          environment: process.env.NODE_ENV
        },
        server: {
          port: Number(process.env.PORT),
          host: process.env.HOST,
          ssl: {
            enabled: process.env.SSL_ENABLED === 'true',
            cert: process.env.SSL_CERT,
            key: process.env.SSL_KEY
          }
        },
        database: {
          host: process.env.DB_HOST,
          port: Number(process.env.DB_PORT),
          name: process.env.DB_NAME,
          pool: {
            min: 2,
            max: 10,
            idle: 30000
          }
        }
      });
      this._instance = rawConfig as DeepReadonly<Config>;
    }
    return this._instance;
  }
}

export const config = ConfigService.load();

Redux/Zustand State: Updates parciais são o pão de cada dia. Com DeepPartial, você pode criar actions que mudam só o que precisa sem repetir o estado inteiro.

import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface AppState {
  user: {
    id: number;
    profile: {
      name: string;
      email: string;
      settings: {
        theme: 'light' | 'dark';
        notifications: boolean;
      };
    };
  } | null;
  ui: {
    loading: boolean;
    errors: string[];
  };
}

interface AppActions {
  // Update parcial type-safe
  updateState: (updates: DeepPartial<AppState>) => void;
  // Actions específicas
  setTheme: (theme: 'light' | 'dark') => void;
  toggleNotifications: () => void;
}

const useStore = create<AppState & AppActions>(
  immer((set) => ({
    user: null,
    ui: {
      loading: false,
      errors: []
    },
    updateState: (updates) => set((state) => {
      // Immer permite mutation syntax
      Object.assign(state, updates);
    }),
    setTheme: (theme) => set((state) => {
      if (state.user) {
        state.user.profile.settings.theme = theme;
      }
    }),
    toggleNotifications: () => set((state) => {
      if (state.user) {
        state.user.profile.settings.notifications = 
          !state.user.profile.settings.notifications;
      }
    })
  }))
);

API Response Types: APIs REST retornam objetos complexos com nested data. Você pode tipar a response como DeepReadonly pra garantir que ninguém muda o payload original. Se precisa modificar algo, force uma cópia explícita.

interface UserResponse {
  data: {
    user: {
      id: number;
      profile: {
        name: string;
        avatar: string;
        bio: string;
      };
      stats: {
        followers: number;
        following: number;
        posts: number;
      };
      posts: Array<{
        id: number;
        title: string;
        content: string;
        created_at: string;
      }>;
    };
  };
  meta: {
    timestamp: number;
    version: string;
  };
}

class UserAPI {
  async getUser(id: number): Promise<DeepReadonly<UserResponse>> {
    const response = await fetch(`/api/users/${id}`);
    return response.json() as DeepReadonly<UserResponse>;
  }
}

// ✅ Não dá pra mudar a response acidentalmente
const user = await api.getUser(1);
user.data.user.profile.name = 'Hacked'; // Error!

// Se precisa modificar, força cópia explícita
const mutableUser = JSON.parse(JSON.stringify(user));
mutableUser.data.user.profile.name = 'Editado'; // OK

Database Models com Timestamps: Você quer que created_at e updated_at sejam readonly sempre, mas outros campos possam ser parciais em updates. Combine DeepReadonly e DeepPartial:

interface Post {
  id: number;
  title: string;
  content: string;
  author: {
    id: number;
    name: string;
  };
  metadata: {
    views: number;
    likes: number;
    tags: string[];
  };
  timestamps: {
    created_at: Date;
    updated_at: Date;
  };
}

// Tipo pra criar novo post (sem id e timestamps)
type CreatePost = Omit<Post, 'id' | 'timestamps'>;

// Tipo pra update (tudo opcional exceto timestamps readonly)
type UpdatePost = DeepPartial<Omit<Post, 'id' | 'timestamps'>> & {
  readonly timestamps: {
    readonly created_at: Date;
    readonly updated_at: Date;
  };
};

class PostRepository {
  async create(data: CreatePost): Promise<Post> {
    const post = {
      ...data,
      id: this.generateId(),
      timestamps: {
        created_at: new Date(),
        updated_at: new Date()
      }
    };
    return post;
  }

  async update(id: number, data: UpdatePost): Promise<Post> {
    // Só atualiza campos que vieram no data
    const current = await this.findById(id);
    return {
      ...current,
      ...data,
      timestamps: {
        created_at: current.timestamps.created_at,
        updated_at: new Date() // Só updated_at muda
      }
    };
  }
}

Essa combinação garante que timestamps nunca são modificados diretamente, mas você ainda pode fazer updates parciais em qualquer outro campo do post. É controle fino sobre mutabilidade.

Performance e Limites do TypeScript

Tipos recursivos são poderosos, mas não são de graça. O compilador TypeScript tem limites de recursão pra evitar loops infinitos e problemas de performance. Você precisa conhecer esses limites.

O limite padrão de profundidade de tipo recursivo é 50 níveis. Se você tentar aplicar DeepReadonly em um objeto que tem mais de 50 níveis de aninhamento, o TypeScript desiste e retorna any. Isso raramente é problema na prática (quem tem objetos com 50 níveis?), mas pode acontecer.

Problema maior: referências circulares. Se você tem dois tipos que se referem um ao outro, tipos recursivos podem entrar em loop:

interface User {
  id: number;
  name: string;
  friends: User[]; // Referência circular!
}

// DeepReadonly vai tentar processar recursivamente para sempre
type ReadonlyUser = DeepReadonly<User>;
// Error: Type instantiation is excessively deep and possibly infinite.

Solução: limitar a profundidade manualmente com um parâmetro de depth:

type DeepReadonlyWithDepth<T, Depth extends number = 5> = 
  Depth extends 0
    ? T
    : T extends (...args: any[]) => any
    ? T
    : T extends any[]
    ? ReadonlyArray<DeepReadonlyWithDepth<T[number], Dec<Depth>>>
    : T extends object
    ? { readonly [K in keyof T]: DeepReadonlyWithDepth<T[K], Dec<Depth>> }
    : T;

// Helper type pra decrementar depth
type Dec<N extends number> = 
  N extends 5 ? 4 :
  N extends 4 ? 3 :
  N extends 3 ? 2 :
  N extends 2 ? 1 :
  N extends 1 ? 0 :
  0;

// Agora para em 5 níveis
type ReadonlyUser = DeepReadonlyWithDepth<User, 5>;

O type Dec é um hack pra decrementar números em tipos. TypeScript não tem operações aritméticas em tipos, então você faz um mapa manual de N → N-1. Quando depth chega em 0, a recursão para e retorna T direto.

Outra preocupação: performance de compilação. Em projetos grandes com muitos tipos recursivos, o TypeScript pode demorar pra compilar. Se o tsc tá levando minutos, considere reduzir o uso de tipos recursivos em hot paths ou aumentar a memória do Node com --max-old-space-size.

Workaround pra referências circulares: use intersection types pra quebrar a circularidade:

interface User {
  id: number;
  name: string;
  friends: UserReference[]; // Usa referência mais leve
}

interface UserReference {
  id: number;
  name: string;
  // Sem nested friends aqui
}

// Agora DeepReadonly funciona sem problemas
type ReadonlyUser = DeepReadonly<User>;

A ideia é quebrar a circularidade usando um tipo mais simples (UserReference) que não tem a referência de volta. Você perde um pouco de type-safety, mas ganha performance e evita erros de recursão infinita.

DeepFreeze: Proteção em Runtime

Tipos recursivos protegem em compile-time, mas não em runtime. TypeScript some quando o código roda. Se você quer proteção real contra mutações, precisa congelar o objeto em runtime também. É aí que entra Object.freeze recursivo.

function deepFreeze<T extends object>(obj: T): DeepReadonly<T> {
  // Congela o objeto atual
  Object.freeze(obj);

  // Itera sobre todas as propriedades
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    const value = (obj as any)[prop];

    // Se a propriedade é um objeto não congelado, congela recursivamente
    if (
      value !== null &&
      (typeof value === 'object' || typeof value === 'function') &&
      !Object.isFrozen(value)
    ) {
      deepFreeze(value);
    }
  });

  return obj as DeepReadonly<T>;
}

// Uso combinado: type-safety + runtime protection
const config = deepFreeze({
  host: 'localhost',
  port: 3000,
  database: {
    host: 'db.example.com',
    port: 5432,
    credentials: {
      username: 'admin',
      password: 'secret'
    }
  }
});

// ❌ Bloqueado em compile-time pelo DeepReadonly
config.port = 4000; // TypeScript error

// ❌ Bloqueado em runtime pelo Object.freeze
try {
  (config as any).port = 4000; // Bypass do TypeScript
  // Em strict mode: TypeError: Cannot assign to read only property
  // Em modo normal: silenciosamente ignorado
} catch (e) {
  console.error('Tentativa de mutação bloqueada!');
}

A função deepFreeze percorre recursivamente todas as propriedades do objeto e aplica Object.freeze em cada uma. O resultado é um objeto completamente imutável, protegido tanto em compile-time (DeepReadonly) quanto em runtime (Object.freeze).

Detalhe importante: Object.freeze só funciona em strict mode. Se você não tem 'use strict' (ou "strict": true no tsconfig), tentativas de mutação são silenciosamente ignoradas, o que é pior porque você não vê o erro. Sempre use strict mode quando trabalhar com frozen objects.

Performance considerations: Object.freeze tem custo em runtime, especialmente em objetos grandes. Só use deepFreeze em objetos que realmente precisam de imutabilidade garantida (configs, constants, etc). Não aplique em todo o state do Redux - o overhead não vale a pena.

Versão otimizada com cache pra evitar processar o mesmo objeto múltiplas vezes:

function deepFreeze<T extends object>(
  obj: T,
  cache = new WeakSet()
): DeepReadonly<T> {
  // Se já processamos esse objeto, pula
  if (cache.has(obj)) {
    return obj as DeepReadonly<T>;
  }

  // Adiciona no cache antes de processar (evita circularidades)
  cache.add(obj);

  // Congela o objeto
  Object.freeze(obj);

  // Processa propriedades recursivamente
  Object.getOwnPropertyNames(obj).forEach((prop) => {
    const value = (obj as any)[prop];

    if (
      value !== null &&
      (typeof value === 'object' || typeof value === 'function') &&
      !Object.isFrozen(value)
    ) {
      deepFreeze(value, cache); // Passa o cache adiante
    }
  });

  return obj as DeepReadonly<T>;
}

// Agora lida com referências circulares
interface Node {
  value: number;
  next: Node | null;
}

const node1: Node = { value: 1, next: null };
const node2: Node = { value: 2, next: node1 };
node1.next = node2; // Circular!

const frozenNode = deepFreeze(node1); // Funciona sem loop infinito

O WeakSet serve como cache pra rastrear objetos já processados. Como é weak, não impede garbage collection dos objetos. E resolve o problema de referências circulares - se encontramos um objeto que já tá no cache, pulamos sem processar de novo.

Quando Usar Tipos Recursivos

  • Config objects que não devem mudar após inicialização - DeepReadonly
  • State management com updates parciais (Redux, Zustand) - DeepPartial
  • API responses que você quer proteger contra mutação acidental - DeepReadonly
  • Formulários multi-step onde validação final exige tudo preenchido - DeepRequired
  • Database models com campos readonly (timestamps, audit fields) - combine com Omit
  • Objetos com mais de 2-3 níveis de aninhamento - tipos nativos não protegem
  • Evite em estruturas com referências circulares sem limite de depth
  • Evite processar arrays gigantes com DeepReadonly - impacto de performance
  • Use deepFreeze apenas em constants críticas, não em todo o state
  • Combine type-level + runtime protection pra segurança máxima