Tipando plugins e sistemas extensíveis

1. Fundamentos: Por que tipar sistemas de plugins?

1.1. Os desafios de sistemas extensíveis sem tipos

Sistemas extensíveis sem tipagem forte são frágeis por natureza. Erros que poderiam ser capturados em tempo de compilação aparecem apenas em tempo de execução, geralmente quando o usuário instala um plugin incompatível. A falta de descoberta de API força desenvolvedores a mergulhar em documentação desatualizada ou no código fonte do core para entender como criar um plugin funcional.

1.2. Benefícios da tipagem forte

Com TypeScript, cada plugin se torna um contrato verificável. O autocomplete guia o desenvolvedor, a segurança em tempo de compilação previne erros de interface, e o código se transforma em documentação viva que nunca fica desatualizada.

1.3. Cenário prático: um editor de texto simples

// core/editor.ts
interface EditorCore {
  content: string;
  insert(text: string, position: number): void;
  delete(start: number, end: number): void;
  getSelection(): { start: number; end: number };
}

2. Contratos de plugin com interfaces e tipos genéricos

2.1. Definindo a interface base do plugin

interface Plugin<T = Record<string, unknown>> {
  name: string;
  version: string;
  config?: T;
  init?(core: EditorCore): void | Promise<void>;
  destroy?(): void;
  onEvent?(event: EditorEvent, context: EventContext): void;
}

2.2. Tipos genéricos para configuração

interface SpellCheckPlugin extends Plugin<{ dictionary: string[]; language: string }> {
  name: 'spellcheck';
  config: { dictionary: string[]; language: string };
}

interface AutoSavePlugin extends Plugin<{ interval: number; format: 'md' | 'txt' }> {
  name: 'autosave';
  config: { interval: number; format: 'md' | 'txt' };
}

2.3. Métodos obrigatórios vs. opcionais

Note que init, destroy e onEvent são opcionais. Isso permite plugins mínimos que apenas adicionam atalhos de teclado, sem necessidade de ciclo de vida complexo.

3. Padrão de registro com type safety

3.1. Registro centralizado com mapa tipado

type PluginName = 'spellcheck' | 'autosave' | 'markdown-preview';

type PluginRegistry = {
  [K in PluginName]: PluginConstructor<K>;
};

type PluginConstructor<K extends PluginName> = new () => ExtractPlugin<K>;

type ExtractPlugin<K extends PluginName> = 
  K extends 'spellcheck' ? SpellCheckPlugin :
  K extends 'autosave' ? AutoSavePlugin :
  never;

3.2. Função de registro com validação

const registry = new Map<PluginName, PluginConstructor<PluginName>>();

function registerPlugin<K extends PluginName>(
  name: K,
  constructor: PluginConstructor<K>
): void {
  if (registry.has(name)) {
    throw new Error(`Plugin "${name}" já registrado`);
  }
  registry.set(name, constructor);
}

// Uso correto
registerPlugin('spellcheck', SpellCheckPlugin);

// Erro de compilação: tipo incompatível
// registerPlugin('spellcheck', AutoSavePlugin);

3.3. Evitando colisões com tipos literais

O tipo PluginName como união de literais garante que apenas nomes conhecidos sejam usados, prevenindo colisões acidentais.

4. Tipando hooks e pontos de extensão

4.1. Definição de hooks com tipos de evento

type EditorEvent = 
  | { type: 'before-insert'; text: string; position: number }
  | { type: 'after-insert'; text: string; position: number }
  | { type: 'before-delete'; start: number; end: number }
  | { type: 'after-delete'; start: number; end: number };

interface Hook<T> {
  (event: T, context: EventContext): T | void;
}

type EventContext = {
  core: EditorCore;
  abort(): void;
  modified: boolean;
};

4.2. Sistema de middleware tipado

class PluginMiddleware {
  private hooks: Map<EditorEvent['type'], Hook<EditorEvent>[]> = new Map();

  addHook<T extends EditorEvent['type']>(
    eventType: T,
    hook: Hook<Extract<EditorEvent, { type: T }>>
  ): void {
    const hooks = this.hooks.get(eventType) || [];
    hooks.push(hook as Hook<EditorEvent>);
    this.hooks.set(eventType, hooks);
  }

  execute(event: EditorEvent): EditorEvent {
    const hooks = this.hooks.get(event.type) || [];
    let currentEvent = event;
    for (const hook of hooks) {
      const result = hook(currentEvent, { core: {} as EditorCore, abort: () => {}, modified: false });
      if (result) currentEvent = result;
    }
    return currentEvent;
  }
}

4.3. Contexto compartilhado entre plugins

interface SharedContext {
  plugins: Map<string, Plugin>;
  state: Record<string, unknown>;
  emit(event: EditorEvent): void;
}

function createPluginContext(): SharedContext {
  return {
    plugins: new Map(),
    state: {},
    emit(event) {
      // Notifica todos os plugins
    }
  };
}

5. Plugins que estendem tipos do core (declaration merging)

5.1. Adicionando propriedades via declare module

// plugin-markdown.ts
declare module './core/editor' {
  interface EditorCore {
    renderMarkdown(): string;
    togglePreview(): void;
  }
}

export class MarkdownPlugin implements Plugin {
  name = 'markdown-preview';
  version = '1.0.0';

  init(core: EditorCore): void {
    core.renderMarkdown = () => `# ${core.content}`;
    core.togglePreview = () => {
      console.log('Preview ativado');
    };
  }
}

5.2. Cuidados com poluição de escopo

Use declaration merging apenas em plugins que realmente estendem o core. Prefira composição sobre herança quando possível.

5.3. Exemplo prático

// core/editor.ts
export class Editor implements EditorCore {
  content = '';
  insert(text: string, position: number): void { /* ... */ }
  delete(start: number, end: number): void { /* ... */ }
  getSelection(): { start: number; end: number } { return { start: 0, end: 0 }; }
}

6. Plugins assíncronos e lazy loading tipado

6.1. Carregamento dinâmico com import()

async function loadPlugin<K extends PluginName>(
  name: K
): Promise<PluginRegistry[K]> {
  const module = await import(`./plugins/${name}`);
  return new module.default() as PluginRegistry[K];
}

6.2. Factory functions com tipos condicionais

type PluginFactory<T extends string> = 
  T extends 'spellcheck' ? () => Promise<SpellCheckPlugin> :
  T extends 'autosave' ? () => Promise<AutoSavePlugin> :
  () => Promise<Plugin>;

const factories: Record<PluginName, PluginFactory<PluginName>> = {
  spellcheck: () => import('./plugins/spellcheck').then(m => new m.SpellCheckPlugin()),
  autosave: () => import('./plugins/autosave').then(m => new m.AutoSavePlugin()),
  'markdown-preview': () => import('./plugins/markdown').then(m => new m.MarkdownPlugin()),
};

6.3. Tratamento de erros com tipos union

type PluginResult<T> = 
  | { success: true; plugin: T }
  | { success: false; error: string };

async function safeLoadPlugin<K extends PluginName>(
  name: K
): Promise<PluginResult<PluginRegistry[K]>> {
  try {
    const plugin = await loadPlugin(name);
    return { success: true, plugin };
  } catch (err) {
    return { success: false, error: `Falha ao carregar ${name}: ${err}` };
  }
}

7. Validação de dependências entre plugins

7.1. Tipando dependências obrigatórias

interface PluginWithDeps<T extends PluginName[]> extends Plugin {
  dependencies: T;
  dependsOn<K extends T[number]>(name: K): boolean;
}

class SpellCheckPlugin implements PluginWithDeps<['autosave']> {
  name = 'spellcheck';
  version = '1.0.0';
  dependencies = ['autosave'] as const;

  dependsOn(name: 'autosave'): boolean {
    return this.dependencies.includes(name);
  }
}

7.2. Ordem de inicialização garantida

type DependsOn<T extends PluginName> = { dependencies: T[] };

function initializePlugins(plugins: Plugin[]): void {
  const sorted = topologicalSort(plugins);
  for (const plugin of sorted) {
    plugin.init?.(core);
  }
}

7.3. Detectando dependências circulares

function detectCircularDependencies(plugins: Plugin[]): boolean {
  const visited = new Set<string>();
  const recursionStack = new Set<string>();

  function dfs(name: string): boolean {
    if (recursionStack.has(name)) return true;
    if (visited.has(name)) return false;

    visited.add(name);
    recursionStack.add(name);

    const plugin = plugins.find(p => p.name === name);
    if (plugin && 'dependencies' in plugin) {
      for (const dep of (plugin as any).dependencies) {
        if (dfs(dep)) return true;
      }
    }

    recursionStack.delete(name);
    return false;
  }

  return plugins.some(p => dfs(p.name));
}

8. Boas práticas e padrões avançados

8.1. Usando satisfies para garantir conformidade

const myPlugin = {
  name: 'custom-plugin',
  version: '1.0.0',
  init(core: EditorCore) {
    core.content = 'Customizado!';
  }
} satisfies Plugin;

// myPlugin mantém inferência precisa, mas falha se não conformar com Plugin

8.2. Versionamento de contratos com tipos branded

type Brand<T, B> = T & { __brand: B };
type PluginV1 = Brand<Plugin, 'v1'>;
type PluginV2 = Brand<Plugin<{ apiVersion: 2 }>, 'v2'>;

function registerV2Plugin(plugin: PluginV2): void {
  if (plugin.config?.apiVersion !== 2) {
    throw new Error('Versão de API incompatível');
  }
}

8.3. Testes de tipo com expect-type

import { expectType } from 'expect-type';

const spellPlugin: SpellCheckPlugin = new SpellCheckPlugin();
expectType<{ dictionary: string[]; language: string }>(spellPlugin.config!);
// Erro de compilação se config não tiver os campos esperados

Referências