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
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
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
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'; // ErrorVeja 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
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'; // OKDatabase 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
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 infinitoO 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