Destino Dev

Blog

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:

  1. Carregue a seção "plugins" do config;

  2. Aplique defaults por plugin;

  3. Valide cada configuração com seu guard específico;

  4. 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 as para 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:

  1. Log debug do conteúdo lido do arquivo antes do merge (truncate se grande).

  2. Verifique tipos primitivos (Number, Boolean) imediatamente após parse.

  3. Centralize validações — evita checar o mesmo campo em vários lugares.

  4. 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