Configuração tipada em TypeScript: env, generics e decorators práticos
Por John Pires · 08 de março de 2026

Configuração tipada em TypeScript: env, generics e decorators práticos
1) Motivação e trade-offs
Em code reviews vejo dois padrões recorrentes: projetos que usam process.env sem qualquer verificação, e projetos que reproduzem um objeto de configuração tipado mas fazem casts universais para satisfazer o compilador. Ambos geram riscos: bugs em produção por valores mal parseados, e falso sentimento de segurança tipada. Aqui vou apontar onde esses padrões falham e mostrar alternativas práticas com generics e decorators que equilibram segurança e ergonomia.
O trade-off central é simples: quanto mais validação no runtime, menos surprises em produção; porém mais código para manter e potencial acoplamento. Types-only (somente interfaces) dão conforto ao TypeScript, mas não protegem contra strings vazias, números mal formatados ou variáveis faltantes em tempo de execução.
Neste artigo eu faço como em um code-review: primeiro mostro o jeito errado (intuitivo mas frágil), depois o jeito certo (gerenciável, testável e com ergonomia), passando por um caso simples e um caso avançado com decorators para reduzir boilerplate.
Por que não confiar só no TypeScript?
Typescript garante tipos em tempo de compilação; não garante nada em runtime. Variáveis de ambiente chegam como strings ou undefined. Sem parse e validação aplicados corretamente, um campo tipado como number pode na verdade ser "NaN" e o erro só será percebido em produção.
Quando adicionar validação de runtime
Adicione validação sempre que a configuração for crítica para segurança, performance ou estabilidade (ex.: portas de servidores, chaves, timeouts, URLs). Para flags não críticas, defaults explícitos e fáceis de mudar podem bastar.
2) Fundamentos técnicos
Conceitos que iremos aplicar:
- Parsing: transformar strings de env em number, boolean, arrays, objetos.
- Generics: definir uma função loadConfig que infere tipos e aplica validações, evitando casts espalhados.
- Decorators e metadata: reduzir boilerplate declarando, na classe de configuração, como cada propriedade deve ser parseada e validada.
Parsing robusto
Um parse robusto trata undefined, strings vazias, formatos inválidos e fornece mensagens de erro claras. Nunca confie em JSON.parse direto sem try/catch. Para booleanos, normalize ("1", "true", "yes" => true).
Generics para inferência
Generics permitem que o loader retorne exatamente o tipo que você definiu na interface/classe, sem precisar de assertions manuais. A assinatura loadConfig(schema: Schema): T é poderosa quando combinada com validação que sobe erros amigáveis em runtime.
3) Exemplo 1: caso simples
Primeiro o jeito errado (comum e curto): mapear process.env para um objeto e confiar no TypeScript.
Jeito errado: cast e suposições
Contexto: um microserviço precisa de PORT, DEBUG e TIMEOUT_MS.
// ruim.ts
// O que faz: monta config diretamente de process.env com casts e suposições.
export interface AppConfig {
port: number;
debug: boolean;
timeoutMs: number;
}
export const config: AppConfig = {
port: (process.env.PORT as unknown) as number, // cast perigoso
debug: (process.env.DEBUG === 'true'),
timeoutMs: Number(process.env.TIMEOUT_MS) // pode virar NaN
};
// O que acontece: o compilador fica satisfeito, mas em runtime:
// - PORT continua string e cast não converte
// - timeoutMs pode ser NaN sem aviso
Este padrão é comum porque é curto. O problema: bugs silenciosos; a aplicação inicia com valores inválidos ou falha somente em caminhos específicos.
Jeito certo: loader genérico e parse explícito
Contexto: a mesma config, mas agora com validação e mensagens de erro. A função parseX retorna tipo correto; loadConfig inferirá o tipo via generics.
// loader.ts
// O que faz: fornece helpers de parse e um loadConfig genérico.
export function parseNumber(name: string, value: string | undefined, opts: { required?: boolean; default?: number } = {}) {
if (value == null || value === '') {
if (opts.required) throw new Error(`Missing env ${name}`);
return opts.default;
}
const n = Number(value);
if (Number.isNaN(n)) throw new Error(`Invalid number in ${name}: ${value}`);
return n;
}
export function parseBool(name: string, value: string | undefined, opts: { required?: boolean; default?: boolean } = {}) {
if (value == null || value === '') {
if (opts.required) throw new Error(`Missing env ${name}`);
return opts.default;
}
const v = value.toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(v)) return true;
if (['0', 'false', 'no', 'off'].includes(v)) return false;
throw new Error(`Invalid boolean in ${name}: ${value}`);
}
export type AppConfig = {
port: number;
debug: boolean;
timeoutMs: number;
};
export function loadAppConfig(): AppConfig {
return {
port: parseNumber('PORT', process.env.PORT, { required: true })!,
debug: parseBool('DEBUG', process.env.DEBUG, { default: false })!,
timeoutMs: parseNumber('TIMEOUT_MS', process.env.TIMEOUT_MS, { default: 5000 })!
};
}
// O que acontece: falhas são explícitas com mensagens, default explícito e parsing seguro.
Vantagens: mensagens claras e comportamento determinístico. Desvantagem: boilerplate cresce com mais campos. É aí que generics e decorators ajudam.
4) Exemplo 2: caso avançado do mundo real
Vamos subir um nível: múltiplos serviços (api, worker), prefixos por ambiente, variáveis opcionais e transformação. O objetivo é reduzir boilerplate usando decorators para declarar metadados de cada campo e um loader genérico que usa esses metadados para parsing e validação.
Design da solução
Ideia: criar decorators @EnvVar(options) para propriedades da classe Config. No runtime um function loadConfig(ctor: new() => T) lê metadata e constrói a instância. Não vamos usar bibliotecas externas, apenas um mapa de metadata próprio. Isso mantém tudo testável e independente.
Implementação com decorators
Contexto: classe AppConfig com vários campos, inclusive listas e JSON. Demonstrarei parse seguro e mensagens de erro com contexto.
// decorators-config.ts
// O que faz: define decorators mínimos para anotar propriedades e um loader que aplica parsing.
type ParserFn = (raw: string | undefined) => unknown;
interface Meta { key?: string; parser: ParserFn; required?: boolean; default?: unknown }
const META = new Map<Function, Record<string, Meta>>();
export function EnvVar(opts: { key?: string; required?: boolean; default?: unknown; parser?: ParserFn } = {}) {
return function (target: any, prop: string) {
const ctor = target.constructor as Function;
const record = META.get(ctor) || {};
const key = opts.key || prop.toUpperCase();
const parser: ParserFn = opts.parser || ((v) => v);
record[prop] = { key, parser, required: !!opts.required, default: opts.default };
META.set(ctor, record);
};
}
export function loadConfig<T>(ctor: new () => T): T {
const record = META.get(ctor) || {};
const inst = new ctor();
for (const prop of Object.keys(record)) {
const meta = record[prop];
const raw = process.env[meta.key!];
if ((raw == null || raw === '') && meta.default !== undefined && !meta.required) {
(inst as any)[prop] = meta.default;
continue;
}
try {
const parsed = meta.parser(raw);
if (parsed === undefined && meta.required) throw new Error(`Missing required env ${meta.key}`);
(inst as any)[prop] = parsed;
} catch (err) {
throw new Error(`Error parsing env ${meta.key} for property ${prop}: ${(err as Error).message}`);
}
}
return inst;
}
// O que acontece: você declara só a shape com decorators. loadConfig aplica parsing centralmente e levanta erros legíveis.
Agora a classe de configuração e os parsers auxiliares.
// app-config.ts
// O que faz: define parsers reutilizáveis e a classe de config annotada.
import { EnvVar, loadConfig } from './decorators-config';
const jsonParser = (raw: string | undefined) => {
if (raw == null || raw === '') return undefined;
try { return JSON.parse(raw); } catch { throw new Error('invalid json'); }
};
const numberParser = (raw: string | undefined) => {
if (raw == null || raw === '') return undefined;
const n = Number(raw);
if (Number.isNaN(n)) throw new Error('invalid number');
return n;
};
const boolParser = (raw: string | undefined) => {
if (raw == null || raw === '') return undefined;
const v = raw.toLowerCase();
if (['1','true','yes'].includes(v)) return true;
if (['0','false','no'].includes(v)) return false;
throw new Error('invalid boolean');
};
export class AppConfig {
@EnvVar({ key: 'SERVICE_PORT', required: true, parser: numberParser })
port!: number;
@EnvVar({ key: 'ENABLE_CACHE', default: false, parser: boolParser })
enableCache!: boolean;
@EnvVar({ key: 'FEATURE_FLAGS', default: '{}', parser: jsonParser })
featureFlags!: Record<string, boolean>;
@EnvVar({ key: 'RETRY_DELAYS_MS', default: '[]', parser: (raw) => JSON.parse(raw || '[]') })
retryDelays!: number[];
}
// Uso:
// set process.env.SERVICE_PORT=3000; process.env.ENABLE_CACHE=true
// const cfg = loadConfig(AppConfig);
// O que acontece: cfg terá tipos corretos, com parsing aplicado e erros localizados.
Resultados práticos: ao adicionar um novo campo, só declare a propriedade com @EnvVar e o loader lida com parsing/erros. Isso mantem a inferência de tipos e reduz repetição.
Testando o loader
Automatize testes unitários que garantam mensagens de erro e defaults: simule process.env com valores válidos, inválidos e ausentes. Cobertura de cases evita surpresas.
Dica: em testes, sempre restaurar process.env ao final. Tests que vazam variáveis de ambiente são fontes de comportamento intermitente.
5) Debugging e troubleshooting
Erros comuns:
- Fazer cast direto (as number) em vez de parse — compila, mas não converte string para número.
- Confiar em defaults implícitos — prefira sempre defaults explícitos ou required flag clara.
- Parser que retorna undefined para required fields — cause erro explícito com mensagem.
Logs e mensagens de erro
Mensagens claras são essenciais. Em production, levantar um erro no startup é melhor do que iniciar com valores inválidos. Mensagens no formato: "Missing env X" ou "Invalid number in Y: abc" ajudam o time a agir rápido.
Ferramentas práticas e patterns
Use esses patterns:
- Centralize parsing em helpers testados (parseNumber, parseBool, parseJson).
- Use generics para funções que retornam T para evitar casts em todos os consumidores.
- Para projetos maiores, adote decorators ou schemas (zod/joi) — decorators reduzem boilerplate em classes, schemas são melhores para validação complexa.
Exemplo de erro comum detalhado: arrays de números vindos via env. Muitos fazem split(',') e Number de cada item sem trim. Valores vazios geram NaN. A solução: map com trim, filtrar strings vazias e validar cada item.
// parsing-arrays.ts
// O que faz: parser seguro para arrays de números vindos do env.
export function parseNumberArray(raw: string | undefined) {
if (!raw) return [];
return raw.split(',')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
const n = Number(s);
if (Number.isNaN(n)) throw new Error(`invalid number in array: ${s}`);
return n;
});
}
// O que acontece: previne NaN por valores vazios ou espaços, produz erros legíveis.
Outro problema prático: ordem de inicialização. Alguns frameworks (Nest, Fastify) inicializam antes que você tenha carregado a config. Sempre carregue e valide config no bootstrap da aplicação, falhando rápido se necessário.
Conclusão prática
Resumo direto: nunca confie apenas em tipos do TypeScript para configuração. Invista em parsing centralizado e em validação explícita. Para projetos pequenos, helpers e loadAppConfig já são um grande ganho. Para projetos médios/grandes, decorators ou schemas tipados oferecem equilíbrio entre ergonomia e segurança.
Recomendações finais:
- Exigir e validar variáveis críticas no startup.
- Definir defaults explícitos e documentados.
- Centralizar parsing e testes para evitar repetição e inconsistência.
Ao aplicar essas práticas você reduz incidentes por configuração e melhora a manutenção. Em code-review: procure casts suspeitos, parsing duplicado e mensagens de erro pobres. Substitua por loaders tipados, parsing testado e, quando fizer sentido, decorators que mantêm a classe limpa.
Implementar estas técnicas leva algum esforço inicial, mas o retorno vem em forma de deploys mais seguros, diagnósticos mais rápidos e menos surpresas em produção.
Assuntos: TypeScript, configuration, decorators, generics, env
Leia também
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…
EventEmitter type-safe em TypeScript com generics e mapped types
Aprenda a construir um EventEmitter type-safe em TypeScript usando generics e mapped types: do problema de any ao emitter tipado, com exemplos práticos…
Feature flags type-safe em TypeScript: configuração, generics e type guards
Aprenda a implementar feature flags type-safe em TypeScript: defina keys literais, use generics, type guards e decorators para evitar typos e validar…
Config type-safe em TypeScript: loader genérico com merges e type guards
Padrão prático para loaders de config em TypeScript: substitua casts por um loader genérico que aplica defaults, faz deep merge e valida com type guards…
Autorização type-safe em Node/Express com decorators, branded types e generics
Padrões práticos para autorização type-safe em Node/Express usando decorators, branded types e generics: evite strings mágicas, typagem de IDs e centralize…