34× mais rápido no pior caso: otimizei o match() sem reescrever em nativo
Por que a 'cadeia de ifs' no dispatch do match() da nossa lib de Result/Option não era um problema de Big-O — e como uma tag escondida via Symbol cortou o hot path pela metade e o pior caso em ~34×.
Tinha algo no match() da @consolidados/results que me incomodava. O dispatch era uma cadeia sequencial de ifs: primeiro testa primitivo, depois itera os cases checando key in matcher, depois discriminated union, depois isOk(), isErr(), isSome(), isNone(). A intuição era óbvia: isso é O(n) linear, dá pra fazer melhor com hash table.
A intuição estava certa em uma parte e errada em outra. E a parte errada é onde estava o ganho real.
O diagnóstico
Antes de mexer numa linha, medi cada caminho do dispatch:
| Caminho | Complexidade real |
|---|---|
Primitivos (string/number/symbol) | O(1) — cases[matcher] |
| União discriminada (com 3º arg) | O(1) — cases[matcher[discriminant]] |
| União mista primitivo + objeto | O(C) no nº de cases (loop for...in) |
Result.Ok / Err · Option.Some / None | O(1) em variantes (4 branches), mas cai no loop de união mista antes de chegar no isOk() |
O O(n) real só aparecia no caminho de união mista — raro, e n pequeno. O caminho quente (Result/Option, n=2) era O(1) em Big-O, mas pagava um pile de custo constante a cada chamada: 3 typeof, um for...in desperdiçado, uma checagem falsy de discriminant, mais "isOk" in matcher (prototype walk) + matcher.isOk() (method call) + matcher.unwrap().
Ou seja: o cases já era hash table (V8/SpiderMonkey indexam plain objects por chave). O problema não era falta de hashmap — era que o dispatch iterava em vez de fazer lookup pela chave certa.
A hipótese: tag oculta por Symbol
Se eu colocar um discriminante invisível em cada variante e ler ele no match, o dispatch vira:
1const tag = matcher[TAG]; // 1 property read
2if (tag) return cases[tag](...); // 1 lookupImplementação:
1const TAG = Symbol.for("@consolidados/results.tag");
2
3class Ok<T> {
4 readonly [TAG] = "Ok" as const;
5 // resto da API fluent inalterado
6}Symbol.for(...) garante o mesmo símbolo global mesmo se o módulo for bundleado N vezes. O símbolo é invisível a for...in, Object.keys, JSON.stringify — zero colisão com union types mistas tipo {Other: [...]}.
E o loop da união mista? Inverti: em vez de iterar cases checando key in matcher (O(C)), itero as próprias chaves do matcher (geralmente 1) e olho em cases (O(1) por lookup).
Por que NAPI/Rust/Zig não era a resposta
A primeira tentação ao ver dispatch lento é "vamos pra nativo". Não dá: o dispatch precisa invocar os handlers, que são closures JS. Closures não cruzam a fronteira nativa. Marshalar o matcher + escolher um handler nativamente custaria mais do que o próprio match.
Por que DOD (data-oriented design) não era a resposta
A outra tentação: "joga fora as classes, usa plain data + free functions, dispatch fica trivial". Não dá: a API fluent (result.map(fn).flatMap(g).unwrap()) é requisito. Em JS, "fluent + plain-data" obriga prototype chain (via class ou Object.create com proto compartilhado). Factory com closures por instância — const Ok = v => ({_tag, map: fn => ..., unwrap: () => v}) — aloca uma closure por método por instância e é pior que classe.
O ganho que DOD traria pro dispatch foi capturado pela tag.
Resultado, medido
Bench head-to-head com vitest bench, cinco estratégias (H0 baseline → H4 tag) em dez cenários:
| Cenário | Antes (ops/s) | Depois (ops/s) | Ganho |
|---|---|---|---|
Result.Ok hot path | 12,4 M | 25,4 M | 2,0× |
Result.Err | 11,7 M | 25,5 M | 2,2× |
Option.Some / None | ~11,7 M | 25,4 M | 2,2× |
| União mista n=10 | 5,0 M | 16,8 M | 3,35× |
| União mista n=50 (pior caso real) | 0,5 M | 17,4 M | 🚀 34× |
| Primitivo / discriminado | ≈ baseline | ≈ baseline | 1,05–1,10× |
Sem regressão em nenhum cenário. 103/103 testes passando. API pública não mudou (Symbol é invisível). Bundle não cresceu.
Quando não vale a pena fazer
- Lib menor que essa, ou hot path com
n=2fixo (só Result): o ganho cabe num intervalo de ruído pra muitos usos. Se você não vai medir, talvez nem perceba. - Pattern matching sobre primitivos só: já era O(1). Tag não ajuda.
- Você não tem suite de testes que garanta zero regressão: a tag é invisível, mas mexer em classes-base de uma lib pública sem rede de testes é receita pra bug silencioso.
A lição
"Cadeia de ifs" parecia O(n) de cara. Não era. O custo constante por chamada — typeof repetido, for...in desperdiçado, in que faz prototype walk, method call em vez de property read — é o que somava. Big-O é um modelo útil; quando n é minúsculo, ele esconde mais do que mostra. Medir cada caminho do hot path antes de "otimizar" pelo nome do problema é o que separa um ganho real de uma refatoração que troca seis por meia dúzia.
Detalhes técnicos completos no BENCH.md do repo. Instalação: bun add @consolidados/results.
Sua lib ou serviço está mostrando "cadeia de ifs" no perfil — ou você está pensando em reescrever pra resolver performance? A ConsoliDados é uma boutique de engenharia sênior — diagnosticamos a causa real antes de propor reescrita, e entregamos código em produção com observabilidade real. Conta o caso: respondemos com viabilidade em 24h úteis. → Engenharia de performance