34× faster in the worst case: optimizing match() without going native
Why the 'chain of ifs' in our Result/Option library's match() dispatch wasn't really a Big-O problem — and how a hidden Symbol-keyed tag halved the hot path and cut the worst case by ~34×.
There was something about match() in @consolidados/results that bothered me. The dispatch was a sequential chain of ifs: primitive type check first, then iterate cases checking key in matcher, then discriminated union, then isOk(), isErr(), isSome(), isNone(). The reflex was obvious: that's O(n) linear, hash table fixes it.
The reflex was half right and half wrong. The wrong half is where the real win was.
The diagnosis
Before touching a line, I measured each dispatch path:
| Path | Real complexity |
|---|---|
Primitives (string/number/symbol) | O(1) — cases[matcher] |
| Discriminated union (with 3rd arg) | O(1) — cases[matcher[discriminant]] |
| Mixed primitive + object union | O(C) over case count (for...in loop) |
Result.Ok / Err · Option.Some / None | O(1) in variants (4 branches), but falls into the mixed-union loop before reaching isOk() |
The real O(n) only showed up on the mixed-union path — rare, and n is small. The hot path (Result/Option, n=2) was O(1) in Big-O, but paid a pile of constant cost per call: 3 typeof, a wasted for...in, a falsy discriminant check, then "isOk" in matcher (prototype walk) + matcher.isOk() (method call) + matcher.unwrap().
In other words: the cases object already was a hash table (V8/SpiderMonkey index plain objects by key). The problem wasn't missing a hashmap — the dispatch was iterating instead of looking up by the right key.
The hypothesis: a Symbol-keyed tag
If I put a hidden discriminant on each variant and read it in match, the dispatch becomes:
1const tag = matcher[TAG]; // 1 property read
2if (tag) return cases[tag](...); // 1 lookupImplementation:
1const TAG = Symbol.for("@consolidados/results.tag");
2
3class Ok<T> {
4 readonly [TAG] = "Ok" as const;
5 // rest of the fluent API unchanged
6}Symbol.for(...) guarantees the same global symbol even if the module gets bundled N times. The Symbol is invisible to for...in, Object.keys, JSON.stringify — zero collision risk with mixed-union variants like {Other: [...]}.
What about the mixed-union loop? I flipped it: instead of iterating cases checking key in matcher (O(C)), iterate the matcher's own keys (usually 1) and look them up in cases (O(1) per lookup).
Why NAPI/Rust/Zig wasn't the answer
The first temptation when you see slow dispatch is "let's go native". Doesn't work: the dispatch has to invoke handlers, which are JS closures. Closures don't cross the native boundary. Marshalling the matcher + picking a handler natively would cost more than the match itself.
Why DOD (data-oriented design) wasn't the answer
The other temptation: "ditch the classes, use plain data + free functions, dispatch becomes trivial". Doesn't work either: the fluent API (result.map(fn).flatMap(g).unwrap()) is a requirement. In JS, "fluent + plain-data" forces a prototype chain (via class or Object.create with a shared proto). A factory with per-instance closures — const Ok = v => ({_tag, map: fn => ..., unwrap: () => v}) — allocates a closure per method per instance and is worse than a class.
The win DOD would bring for dispatch was captured by the tag.
The measured result
Head-to-head bench with vitest bench, five strategies (H0 baseline → H4 tag) across ten scenarios:
| Scenario | Before (ops/s) | After (ops/s) | Speedup |
|---|---|---|---|
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× |
| Mixed union n=10 | 5.0 M | 16.8 M | 3.35× |
| Mixed union n=50 (real worst case) | 0.5 M | 17.4 M | 🚀 34× |
| Primitive / discriminated | ≈ baseline | ≈ baseline | 1.05–1.10× |
No regression in any scenario. 103/103 tests green. Public API unchanged (Symbol is invisible). Bundle didn't grow.
When it's not worth doing
- A smaller library, or hot path with fixed
n=2(Result only): the gain fits inside noise for many uses. If you're not going to measure, you may not notice. - Primitive-only pattern matching: already O(1). The tag doesn't help.
- You don't have a test suite that guarantees zero regression: the tag is invisible, but touching base classes of a public library without a safety net is a recipe for silent bugs.
The lesson
"Chain of ifs" looked like O(n) at a glance. It wasn't. The constant cost per call — repeated typeof, wasted for...in, in that prototype-walks, method call instead of property read — was what added up. Big-O is a useful model; when n is tiny, it hides more than it shows. Measuring each hot-path branch before "optimizing" by the name of the problem is what separates a real gain from a refactor that swaps six for half a dozen.
Full technical details in the BENCH.md in the repo. Install: bun add @consolidados/results.
Your library or service is showing a "chain of ifs" in the profile — or you're thinking of rewriting to fix performance? ConsoliDados is a senior engineering boutique — we diagnose the real cause before proposing a rewrite, and we ship code to production with real observability. Tell us the case: we respond with a viability assessment within 24 business hours. → Performance engineering