Pular para o conteúdo
Casos Técnicos

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×.

Casos TécnicosJohnny Carreiro·29 de maio de 2026·4 min de leitura

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:

CaminhoComplexidade real
Primitivos (string/number/symbol)O(1)cases[matcher]
União discriminada (com 3º arg)O(1)cases[matcher[discriminant]]
União mista primitivo + objetoO(C) no nº de cases (loop for...in)
Result.Ok / Err · Option.Some / NoneO(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:

match.ts
1const tag = matcher[TAG];          // 1 property read
2if (tag) return cases[tag](...);   // 1 lookup

Implementação:

ok.ts
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árioAntes (ops/s)Depois (ops/s)Ganho
Result.Ok hot path12,4 M25,4 M2,0×
Result.Err11,7 M25,5 M2,2×
Option.Some / None~11,7 M25,4 M2,2×
União mista n=105,0 M16,8 M3,35×
União mista n=50 (pior caso real)0,5 M17,4 M🚀 34×
Primitivo / discriminado≈ baseline≈ baseline1,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=2 fixo (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