Destino Dev

Blog

Sistema de plugins type-safe em TypeScript com decorators e generics

Por John Pires · 13 de março de 2026

Sistema de plugins type-safe em TypeScript com decorators e generics

1) Problema real que resolve

Cenário

Projetos médios e grandes costumam expor pontos de extensão: autenticação, armazenamento, transformações de payload, observability. Esses pontos viram "plugins" quando queremos permitir trocas e extensões sem alterar o core. Na prática, cada plugin tem sua própria configuração e contrato: tipos de opções, lifecycle hooks, e APIs que o core espera usar. Com TypeScript, o desafio é garantir que o core e cada plugin concordem sobre tipos tanto em tempo de compilação quanto em runtime.

Sem disciplina, times acabam com tentativas ad-hoc: configs com any, validações ad-hoc em strings JSON, ou contratos implícitos que quebram quando um plugin é atualizado. Isso leva a bugs sutis que aparecem em produção: propriedades obrigatórias ausentes, tipos errados, e integrações que falham somente quando determinadas combinações de plugins são usadas.

Consequências

As consequências incluem deploys inseguros, dificuldade para refatorar, e testes frágeis. Quando a extensão é feita por terceiros (plugins de comunidade) o risco aumenta: é preciso ter validação de runtime e, se possível, ajudar o autor do plugin com tipos fortes. Uma solução robusta precisa combinar segurança em tempo de compilação (generics, utility types) com checks em runtime (type guards, validação de configuração) e uma forma declarativa de registro (decorators ou fábricas).

2) Abordagem ingênua vs abordagem otimizada

Abordagem ingênua

Uma implementação ingênua registra plugins como qualquer função ou objeto e aceita configuração como Record. Na inicialização, o core passa esse objeto para cada plugin e confia que tudo está certo. Isso funciona até que um valor inválido cause uma exceção em produção ou quando múltiplos plugins têm nomes conflituosos.

Exemplo rápido da abordagem with any (ilustra o problema):

// O que faz: registro simples de plugins sem segurança de tipos. Aceita qualquer configuração (any).
interface AnyPlugin {
  name: string;
  init(config: any): void;
}

class AnyPluginManager {
  private plugins: AnyPlugin[] = [];
  register(p: AnyPlugin) {
    this.plugins.push(p);
  }
  start(configMap: Record<string, any>) {
    for (const p of this.plugins) {
      p.init(configMap[p.name]);
    }
  }
}

// O que acontece: você perde garantias. Erros aparecem em runtime.

Depois desse bloco fica claro: sem tipos, qualquer mudança quebrará em runtime. Testes unitários reduzem o risco, mas a grande vantagem do TypeScript é eliminar muitas dessas classes de erro em tempo de compilação.

Abordagem otimizada: type-safe

A abordagem otimizada combina:

  • Generics para associar cada plugin a uma forma de configuração;
  • Decorators ou fábricas para registrar metadata de plugin (nome, versão, esquema de config opcional);
  • Type guards para validação em runtime e para ajudar o TypeScript a estreitar tipos;
  • Mapeamento de tipos (mapped types) para derivar, a partir da lista de plugins, o shape da configuração global exigida pelo sistema.

Essa combinação permite que o desenvolvedor do core declare e consuma plugins sem perder segurança de tipos, e que os autores de plugins tenham um caminho claro para expor seus tipos de configuração.

3) Implementação com código real

Interfaces e tipos base

Vamos definir uma base que usa generics para amarrar um plugin ao seu tipo de configuração e ao nome (string literal). O padrão a seguir é suficientemente simples para ser adotado e flexível o bastante para se estender.

// O que faz: define interfaces e tipos auxiliares para plugins type-safe.
export interface Plugin<C = unknown> {
  init(config: C): Promise<void> | void;
}

// Construtor com propriedade estática pluginName (literal string) para inferência.
export type PluginConstructor<C = any, N extends string = string> = {
  new (...args: any[]): Plugin<C>;
  pluginName: N;
};

// Utility type: a partir de um array de construtores de plugin (tuple) gera um mapa nome->config
export type ConfigMapFromConstructors<PL extends readonly PluginConstructor<any, string>[]> = {
  [P in PL[number] as P['pluginName']]: P extends PluginConstructor<infer C, any> ? C : never;
};

// O que acontece: podemos usar esses tipos para inferir o tipo da configuração global quando passamos um array de plugins com 'as const'.

Explicação: a propriedade estática pluginName no construtor é essencial para que o tipo mapeado use o literal string como chave. Ao passar plugins como um array declarado com as const, TypeScript preserva os literais, permitindo mapear para o tipo de configuração correspondente.

Decorators e registry

Decorators são úteis para reduzir boilerplate no plugin: podemos usar um decorator de classe para setar a propriedade estática pluginName e opcionalmente registrar o plugin em uma lista global.

// O que faz: decorator factory para atribuir nome ao plugin e opcionalmente registrar.
export function PluginMeta<N extends string>(name: N) {
  return function <T extends { new (...args: any[]): any }>(ctor: T) {
    // atribui propriedade estática para inferência de tipos
    (ctor as any).pluginName = name;
    // Optional: registro automático em um registry global (pode ser útil para CLIs ou discovery)
    PluginRegistry.register(ctor as any);
    return ctor;
  };
}

// Um registry simples: mantem construtores registrados.
export const PluginRegistry = (() => {
  const set = new Set<PluginConstructor<any, string>>();
  return {
    register(ctor: PluginConstructor<any, string>) {
      set.add(ctor);
    },
    list() {
      return Array.from(set.values());
    },
  };
})();

// O que acontece: usar @PluginMeta('auth') em uma classe seta a propriedade pluginName e registra a classe.

Nota: decorators exigem "experimentalDecorators": true no tsconfig. Registries globais ajudam discovery, mas para controle e testabilidade prefira passar explicitamente a lista de plugins ao manager.

PluginManager com inferência de tipos

O manager vai aceitar um array de construtores (com as const) e derivar o tipo da configuração global. Assim, chamar manager.initAll(config) será verificado em compile-time.

// O que faz: PluginManager deriva tipos e inicializa plugins com a configuração correta.
export class PluginManager<PL extends readonly PluginConstructor<any, string>[]> {
  private instances: Plugin[] = [];

  constructor(private plugins: PL) {}

  async initAll(configs: ConfigMapFromConstructors<PL>) {
    for (const Ctor of this.plugins) {
      const name = (Ctor as any).pluginName as string;
      const instance = new Ctor();
      const cfg = (configs as any)[name];
      // runtime guard poderia ser chamado aqui
      await instance.init(cfg);
      this.instances.push(instance);
    }
  }
}

// Uso (o que acontece): com 'as const' o tipo de configs é derivado e checado em tempo de compilação.

Exemplo de uso real a seguir mostra dois plugins com configurações distintas e o manager inicializando-os com garantia de tipos.

// O que faz: exemplos concretos de plugins (auth e logger) e inicialização do manager.
@PluginMeta('auth')
class AuthPlugin implements Plugin<{ provider: 'jwt' | 'oauth'; secret?: string }> {
  async init(config: { provider: 'jwt' | 'oauth'; secret?: string }) {
    if (config.provider === 'jwt' && !config.secret) throw new Error('jwt requires secret');
    // inicialização...
  }
}

@PluginMeta('logger')
class LoggerPlugin implements Plugin<{ level: 'info' | 'debug' }> {
  init(config: { level: 'info' | 'debug' }) {
    // configurar logger...
  }
}

const manager = new PluginManager([AuthPlugin, LoggerPlugin] as const);

// O que acontece: o tipo de `cfg` é inferido como { auth: { provider: 'jwt'|'oauth'; secret?: string }; logger: { level: 'info'|'debug' } }
await manager.initAll({ auth: { provider: 'jwt', secret: 's' }, logger: { level: 'debug' } });

Detalhe: para que a inferência funcione bem você precisa passar a lista com as const. Sem isso, os literais são widen e a derivação perde precisão.

4) Testes e validação

Type-level checks e type guards

Tipos em tempo de compilação evitam muitos erros, mas não substituem validação de runtime. Use type guards para validar shapes essenciais antes de chamar init. Um type guard pode ser simples (verificar propriedade obrigatória) ou baseado em bibliotecas de schemas (zod, io-ts).

// O que faz: exemplo simples de type guard para a configuração do AuthPlugin.
export function isAuthConfig(v: any): v is { provider: 'jwt' | 'oauth'; secret?: string } {
  return (
    v &&
    (v.provider === 'jwt' || v.provider === 'oauth') &&
    (v.provider !== 'jwt' || typeof v.secret === 'string')
  );
}

// O que acontece: pode ser chamado antes do init para falhar cedo e com mensagem clara.

Combine type guards com mensagens e logging claros. Para validação robusta, prefira schemas bem definidos e a validação automática deles.

Exemplos de testes unitários

Um teste básico verifica que o manager exige o formato correto de config em tempo de compilação (TypeScript) e que o runtime valida as propriedades. Em JS runtime você faz asserts; em TS você também confia no compilador.

// O que faz: exemplo de teste com assert simples (pseudocódigo usando node assert)
import assert from 'assert';

async function testInit() {
  const manager = new PluginManager([AuthPlugin, LoggerPlugin] as const);

  // Compile-time: se comentar secret, TS reclama no caso jwt
  await manager.initAll({ auth: { provider: 'jwt', secret: 's' }, logger: { level: 'info' } });

  // Runtime: erro esperado se config inválida
  let threw = false;
  try {
    await manager.initAll({ auth: { provider: 'jwt' }, logger: { level: 'info' } } as any);
  } catch (e) {
    threw = true;
  }
  assert.ok(threw, 'deve lançar quando jwt sem secret');
}

// O que acontece: testes verificam tanto checagens TS (manual) quanto comportamento runtime.

Para automação, inclua testes que exercitem entradas inválidas (fuzz), além de testes de integração que carregam plugins reais. Ferramentas como tsd ajudam a testar garantias de tipos em projetos TypeScript.

5) Considerações de produção

Performance e lazy loading

Registrar muitos plugins e instanciá-los todos na inicialização pode afetar cold-starts. Estratégias:

  • Lazy init: inicialize um plugin apenas quando seu serviço for solicitado;
  • Code-splitting: carregue o código do plugin dinamicamente (import()) para reduzir bundle/cold-start;
  • Pooling e reuso: para plugins que abrem conexões, reutilize instâncias quando possível.

Implementar lazy init no PluginManager é trivial: mantenha constructors e só instancie quando uma rota/feature requisitar. Mantenha porém as validações de configuração no startup para falhar rápido quando configurações obrigatórias estiverem ausentes.

Segurança e validação de configuração

Valide toda configuração externa (env, arquivos, inputs) antes de passar para plugins. A combinação ideal: schema-runtime (zod/io-ts) + type guard + typescript types. Isso garante mensagens de erro legíveis e segurança contra malformação.

  • Use schemas para gerar docs e validação;
  • Não confie em declarações de plugin para sanitizar dados entrantes;
  • Trate versões — plugins podem mudar shape de config entre versões; considere versionamento no metadata.
Tip: adicione versão no decorator: @PluginMeta('auth@1') ou metadata.split(':version') para permitir migrações e validações específicas por versão.

Erros comuns e armadilhas

Listei aqui problemas que aparecem na adoção desse pattern:

  1. Esquecer de usar as const ao declarar a lista de plugins — causa perda de literal types e perda de inferência do mapa de configs.
  2. Usar any no tipo de constructo ou na propriedade pluginName faz com que o mapped type erre; sempre declare a propriedade estática com um literal ou um tipo restringido.
  3. Confiar apenas em tipos TS sem validação runtime — objetos vindos de JSON (s3, env) ainda podem ser inválidos.
  4. Registrar plugins dinamicamente sem controle pode provocar colisões de nomes; normalize nomes e verifique duplicates no registry.

Correto tratamento desses pontos reduz grandes dores na manutenção. Em equipes, documente como criar um novo plugin: exemplo de template, schema de validação e checklist de registro.

Conclusão prática

Construir um sistema de plugins type-safe em TypeScript exige combinar recursos de linguagem (generics, utility types), metaprogramação leve (decorators ou fábricas) e validação em runtime (type guards ou schemas). O resultado é um equilíbrio entre flexibilidade para extensões e segurança para o core do sistema.

Recomendações práticas finais:

  • Padronize a forma de expor metadata do plugin (pluginName, version, schema);
  • Exija types explicitamente no construtor/classes e use as const ao montar arrays de plugins;
  • Valide runtime usando schemas e type guards no startup para falhar rápido com mensagens claras;
  • Documente um template de plugin com exemplos e testes; facilite a interoperabilidade.

Com esse padrão você reduz bugs, habilita autocompletar e refactor seguro, e cria um ecossistema confiável para plugins internos ou de terceiros. Esse é o trade-off: um pouco mais de boilerplate no registro e validação por vantagens grandes em manutenção e confiança em produção.

Assuntos: TypeScript, decorators, generics, type-guards, plugins

Leia também