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
- TypeScript Handbook: Generics — Documentação oficial sobre tipos genéricos, fundamentais para criar contratos de plugin flexíveis
- TypeScript: Declaration Merging — Guia completo sobre como estender interfaces existentes via module augmentation
- TypeScript: Conditional Types — Explica como usar tipos condicionais para criar factories e validações de dependência
- TypeScript: satisfies Operator — Artigo sobre o operador
satisfiespara verificação de tipos sem perder inferência - expect-type: Type Testing Library — Biblioteca para testes de tipo em TypeScript, útil para garantir que plugins inválidos não compilam
- TypeScript: Module Resolution — Documentação sobre resolução de módulos, essencial para lazy loading de plugins
- Patterns for Extensible TypeScript Libraries — Boas práticas para estruturar bibliotecas extensíveis com tipagem forte