Config type-safe em TypeScript: loader genérico com merges e type guards
Por John Pires · 14 de março de 2026

Config type-safe em TypeScript: loader genérico com merges e type guards
Motivacao e trade-offs
Configurar uma aplicação em Node/TypeScript parece trivial: ler um JSON, aplicar defaults e usar. Na prática, essa zona é uma fonte recorrente de bugs: valores ausentes, tipos inesperados, chaves extras ou merges incorretos entre defaults, env e arquivos por ambiente. Este artigo age como um code-review — mostro o jeito errado (comum em projetos) e o jeito certo, com generics, type guards e merges previsíveis.
Quando falo "jeito errado" não é por dogma: é porque patterns comuns sacrificam safety por conveniência. Vamos comparar trade-offs:
Conveniência: usar cast "as" e Object.assign é rápido, mas oculta erros.
Segurança: validar em runtime com type guards tem custo extra, mas previne crashes em produção.
Complexidade: tipos genéricos e utilitários (DeepMerge, RequiredKeys) adicionam complexidade ao código, mas pagam em manutenção.
Escolha sensata: para configs críticas (DB, auth, APIs), priorize validação e tipos; para partes experimentais, prefira iterações rápidas com testes cobertos. O padrão que proponho é um loader genérico com validação runtime e tipos compile-time que se reforçam mutuamente.
Problema comum (exemplo do jeito errado)
Aqui vai um exemplo real e frequente: um loader que lê config.json e faz cast usando "as". É curto, funciona localmente e quebra em produção quando o arquivo está corrompido.
// Arquivo: bad-loader.ts
import fs from 'fs';
interface AppConfig {
port: number;
host: string;
featureX?: { enabled: boolean };
}
const DEFAULTS: AppConfig = { port: 3000, host: '127.0.0.1', featureX: { enabled: false } };
export function loadConfig(): AppConfig {
const raw = JSON.parse(fs.readFileSync('config.json', 'utf8'));
// Problema: cast direto e merge superficial
return Object.assign({}, DEFAULTS, raw) as AppConfig;
}
O que acontece: o merge é superficial (Object.assign não faz deep merge), e o cast "as AppConfig" remove qualquer verificação em runtime. Se config.json trouxer { port: "3000" } ou featureX: null, o app pode cair em runtime.
Trade-offs a considerar
Antes de redesenhar, considere estes trade-offs técnicos:
Deep merge previsível vs performance em inicialização (deep merge custa CPU para objetos grandes).
Validação runtime (type guards) vs bibliotecas externas (zod/joi). Type guards custom dão controle e menor dependência, libs dão ergonomia e mensagens prontas.
Tipagem genérica do loader (flexível) vs complexidade no typing (padrões utilitários como DeepPartial podem confundir).
Fundamentos tecnicos
Vou cobrir os elementos técnicos que formam a solução: generics para o loader, utility types para merges, type guards para validação e uma função de deep merge com comportamento previsível.
Generics permitem que o loader seja reutilizável sem perder a tipagem de configurações específicas. O loader expõe loadConfig<T>(path, defaults, guard) e retorna T.
Tipos utilitários importantes
Algumas definições úteis:
DeepPartial<T> — útil para representar overrides parciais;
DeepMerge — define comportamento do merge em tipos (objetos ganham recursivamente, arrays substituídos por padrão);
RequiredKeys<T, K> — força keys obrigatórias numa variante do tipo.
// Arquivo: types.ts
export type DeepPartial = {
[P in keyof T]?: T[P] extends object ? DeepPartial : T[P];
};
export type Merge = {
[K in keyof A | keyof B]: K extends keyof B
? K extends keyof A
? A[K] extends object
? B[K] extends object
? Merge
: B[K]
: B[K]
: B[K]
: K extends keyof A
? A[K]
: never;
};
Contexto: acima temos utilitários para modelar operações de merge no nível de tipo. Eles não fazem o trabalho em runtime, mas alinham a assinatura do loader com o comportamento esperado.
Type guards e validação em runtime
Type guards são funções que, em tempo de execução, verificam a forma do objeto e informam o compilador. Eles evitam casts perigosos. Exemplo de guard minimalista:
// Arquivo: guards.ts
export function isObject(v: unknown): v is Record {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
export function isNumber(v: unknown): v is number {
return typeof v === 'number' && Number.isFinite(v);
}
Contexto: esses guards são pequenos blocos reutilizáveis. Em configs, evite confiar apenas em typeof: cheque arrays, nulidade e propriedades obrigatórias explicitamente.
Exemplo 1: caso simples
Vamos montar um loader para um AppConfig simples. Requisitos: ler arquivo JSON, aplicar defaults com deep merge, validar o resultado com type guard e devolver o tipo correto sem casts.
Especificação e defaults
Definimos uma interface clara e defaults obrigatórios. Defaults são a nossa "fonte da verdade" para keys esperadas.
// Arquivo: app-config.ts
export interface AppConfig {
port: number;
host: string;
featureX?: { enabled: boolean };
}
export const DEFAULTS: AppConfig = {
port: 3000,
host: '127.0.0.1',
featureX: { enabled: false },
};
Contexto: DEFAULTS garante que todas as propriedades obrigatórias existam e define comportamento padrão para opcionais.
Implementacao do loader genérico
Implementamos um deepMerge runtime, um type guard para AppConfig e o loader genérico. O loader aceita um guard que valida o objeto final.
// Arquivo: loader.ts
import fs from 'fs';
import { AppConfig, DEFAULTS } from './app-config';
import { isObject, isNumber } from './guards';
function deepMerge(target: T, source: unknown): T {
if (!isObject(source)) return target;
const out: any = Array.isArray(target) ? [...(target as any)] : { ...(target as any) };
for (const key of Object.keys(source)) {
const sv: any = (source as any)[key];
if (key in out && isObject(out[key]) && isObject(sv)) {
out[key] = deepMerge(out[key], sv);
} else {
out[key] = sv;
}
}
return out as T;
}
function isAppConfig(v: unknown): v is AppConfig {
if (!isObject(v)) return false;
const o = v as any;
if (!isNumber(o.port)) return false;
if (typeof o.host !== 'string') return false;
if ('featureX' in o) {
if (!isObject(o.featureX)) return false;
if (typeof o.featureX.enabled !== 'boolean') return false;
}
return true;
}
export function loadConfig(path = 'config.json'): AppConfig {
const raw = fs.existsSync(path) ? JSON.parse(fs.readFileSync(path, 'utf8')) : {};
const merged = deepMerge(DEFAULTS, raw);
if (!isAppConfig(merged)) {
throw new Error('Config inválida: falha na validação');
}
return merged;
}
O que acontece: o deepMerge copia recursivamente apenas quando ambos lados são objetos. Arrays serão substituídos por padrão. A validação garante tipos esperados antes de devolver a configuração.
Exemplo 2: caso avancado do mundo real
Agora um cenário mais próximo de aplicações modulares: uma aplicação com plugins. Cada plugin tem sua própria configuração tipada. Queremos um loader que:
Carregue a seção "plugins" do config;
Aplique defaults por plugin;
Valide cada configuração com seu guard específico;
Permita injeção de config com tipos em classes de plugin (decorator/mixin).
Plugins e merges profundos
Estratégia: manter um registro de plugins com suas definicoes de tipo e guard. O loader itera por esse registro e monta um mapa tipado. O principal desafio é manter segurança de tipos apesar dos plugins serem dinâmicos.
Definição de registro:
// Arquivo: plugin-registry.ts
export type PluginGuard = (v: unknown) => v is T;
export interface PluginEntry {
name: string;
defaults: T;
guard: PluginGuard;
}
const registry: Record> = {};
export function registerPlugin(entry: PluginEntry) {
if (registry[entry.name]) throw new Error('Plugin já registrado: ' + entry.name);
registry[entry.name] = entry;
}
export function getRegistry() {
return registry;
}
Contexto: o registro guarda defaults e guards para cada plugin. A tipagem usa any internamente porque registramos dinamicamente, mas a API de registro é genérica, exigindo guard na hora do registro.
Injecao via decorator e API de uso
Implementamos um helper que cria uma classe wrapper injetando a configuração tipada do plugin. Em vez de manipular property decorators complexos, usamos um class decorator que adiciona uma propriedade config ao protótipo — suficiente para demonstração e tipagem local.
// Arquivo: plugin-inject.ts
import { getRegistry } from './plugin-registry';
export function WithPluginConfig(pluginName: string) {
return function(Ctor: T) {
const reg = getRegistry();
const entry = reg[pluginName];
if (!entry) throw new Error('Plugin não registrado: ' + pluginName);
return class extends Ctor {
public config = entry.defaults as P;
constructor(...args: any[]) {
super(...args);
// Em runtime você poderia sobrescrever com os carregados do loader
}
} as any;
};
}
// Exemplo de uso (plugin type-safe)
interface MetricsConfig { enabled: boolean; endpoint?: string }
// registerPlugin({ name: 'metrics', defaults: { enabled: false }, guard: (v): v is MetricsConfig => true });
// @WithPluginConfig('metrics')
// class MetricsPlugin { public config!: MetricsConfig; }
O que acontece: o decorator cria uma classe derivada que expõe config com o tipo do plugin. Em runtime, o loader deve preencher esses defaults com valores do arquivo e validar antes de usar.
Debugging e troubleshooting
Mesmo com validação, você encontrará problemas: merges inesperados, valores nulos, e configs com tipos errados vindos de variáveis de ambiente (strings). Aqui estão táticas práticas para depurar.
Erros comuns e armadilhas
Lista de problemas que já vi em code-reviews:
Uso de
aspara forçar tipos depois do merge — esconde bugs.Object.assign para merge profundo — sobrescreve objetos inteiros quando você queria mesclar propriedades internas.
Variáveis de ambiente tratadas como strings sem conversão (porta like "3000").
Nulls inesperados que quebram validações simples.
// Arquivo: common-errors.ts
// Exemplo: env -> porta
const envPort = process.env.PORT; // string | undefined
const port = Number(envPort) || 3000; // melhor converter explicitamente
// Erro comum:
const config = JSON.parse(process.env.APP_CONFIG || '{}') as any; // perigoso
O que acontece: converter explicitamente e validar com guards previne erros sutilmente introduzidos por devops ou mudanças de infra.
Dicas práticas de troubleshooting
Regra prática: falhe cedo e com mensagem clara. Valide no start-up, não em runtime disperso.
Checklist para debugar loader:
Log debug do conteúdo lido do arquivo antes do merge (truncate se grande).
Verifique tipos primitivos (Number, Boolean) imediatamente após parse.
Centralize validações — evita checar o mesmo campo em vários lugares.
Adote testes unitários para o loader cobrindo merges e casos inválidos.
Ferramentas: se você preferir esforço menor, bibliotecas como zod/joi/ajv automatizam validações e geram mensagens úteis. Contudo, type guards custom mantêm dependências baixas e melhor performance isolada.
Conclusao
Resumo prático: pare de usar casts indiscriminados em config. Use um loader genérico com:
defaults explícitos;
deep merge controlado;
type guards para validar o objeto final;
API genérica para permitir plugins e injeção tipada.
Se quiser segurança adicional, troque guards por uma lib de schema (zod) e mantenha a mesma arquitetura: defaults + merge + validação. Em todos os casos, falhe durante a inicialização com mensagem clara.
Próximos passos práticos: escreva testes unitários para seu loader, trace logs dos merges em CI quando um deploy falhar e adicione verificação de schema em pipelines de PR para detectar regressões de config.
Este artigo mostrou o caminho errado (rápido e frágil) e o caminho certo (explicitamente tipado e validado). Implemente a versão certa em módulos críticos e mantenha a ergonomia onde a velocidade de iteração for prioridade.
Assuntos: TypeScript, generics, type-guards, config, decorators
Leia também
Configuração tipada em TypeScript: env, generics e decorators práticos
Como evitar casts perigosos em process.env e construir loaders tipados em TypeScript usando generics e decorators. Padrões práticos, erros comuns e…
Converter JSON snake_case para camelCase com TypeScript e Generics
Aprenda passo a passo a converter JSON snake_case para camelCase usando TypeScript: funções runtime recursivas, tipos genéricos DeepCamelCase e boas práticas…
DTOs type-safe em TypeScript: decorators, generics e type guards passo a passo
Aprenda a transformar JSON em DTOs type-safe com TypeScript usando decorators, generics e type guards. Guia passo a passo para iniciantes: configuração…
Sistema de plugins type-safe em TypeScript com decorators e generics
Implementação prática de um sistema de plugins type-safe em TypeScript usando decorators, generics e type guards. Padrões, armadilhas comuns e exemplos reais…
Client HTTP type-safe com Fetch: decorators, generics e type guards
Padrão prático para construir um client HTTP type-safe com fetch usando decorators, generics e type guards: validação runtime, exemplos implementáveis e…
Config type-safe em Node.js com TypeScript: Zod, generics e utility types
Aprenda a fazer configuração type-safe em Node.js com TypeScript usando Zod, generics e utility types. Passo a passo prático para validar e inferir tipos do…