perf: reduce type instantiations for literal and discriminated-union matches - #357
Open
gwkline wants to merge 5 commits into
Open
perf: reduce type instantiations for literal and discriminated-union matches#357gwkline wants to merge 5 commits into
gwkline wants to merge 5 commits into
Conversation
Matching large enums or string literal unions member-by-member was expensive because each .with() call re-instantiated the full pattern machinery (Pattern<i>, InvertPatternForExclude, ExtractPreciseValue, DeepExclude) on the narrowed input union, even though primitive patterns can't interact with object, array, Map or Set decomposition. Add semantically-equivalent fast paths for primitive/literal patterns: - Pattern: skip object/array pattern computation for all-primitive inputs - InvertPatternForExclude: literals invert to themselves; skip the Equal<Pattern<i>, p> comparison - DeepExclude: primitives only exclude top-level union members, so plain Exclude is equivalent to distributing matching unions - ExtractPreciseValue: jump straight to LeastUpperBound On a 400-member string union matched member-by-member with .exhaustive(): instantiations 2.47M -> 1.68M (-32%), check time 1.83s -> 1.41s. No regression on the object-heavy test suite (3.69M -> 3.64M instantiations). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n ExtractPreciseValue When extracting the precise value of an object pattern from a union, ExtractPreciseValue built the full merged object type (two mapped types, an intersection and a Compute) for every member of the input union before checking whether the pattern's keys extracted `never`. For discriminated unions, that meant paying the full merge cost for every non-matching member, only to throw the result away. Compute the pattern-side mapped type first and bail out with `never` before merging in the input's other properties. The result type is unchanged: the never-check only ever inspected the pattern's own keys. On a real-world 21-variant discriminated union matched with per-discriminant object patterns and .exhaustive(): instantiations 485k -> 315k (-35%), and the match expression's check time 520ms -> 196ms. Non-exhaustive (mid-edit) variants of the same match improve similarly (548k -> 386k instantiations). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gwkline
marked this pull request as draft
July 25, 2026 16:06
A primitive pattern that matches the input type is provably equal to its own inverted pattern (InvertPattern) and matched value (MatchedValue), and it can't contain selections (FindSelected). Its excluded type only depends on the pattern itself (InvertPatternForExclude<p, p>), which the primitive fast path resolves cheaply. This means we can dispatch primitive patterns to a dedicated .with() overload that skips all of that machinery. The overload only accepts patterns extending `i & Primitives` (opting out when the input is `any`, since `any & Primitives` would accept any pattern), so object patterns, arrays and P.* matchers fall through to the general overloads with unchanged behavior. Non-literal primitive patterns (e.g. a `string`-typed value) still don't count towards exhaustiveness, as before. On a 200-member string literal union matched member-by-member with .exhaustive(): instantiations 580k -> 227k (-61%), check time 0.66s -> 0.42s. Combined with the previous fast paths, this brings the case down from 785k instantiations on ts-pattern v5.9.0 (-71%). No change on object-pattern matches; the whole-test-suite type check improves from 3.67M to 3.55M instantiations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gwkline
force-pushed
the
gwkline/optimize-string-exhaustiveness
branch
from
July 25, 2026 16:31
b4b35ad to
8bc1444
Compare
Add type-checking benchmarks under benchmarks/compile-time/ covering the two match shapes the perf commits in this branch target: - a 200-member string literal union matched member by member - a 20-variant discriminated union of wide object types matched with refined, 2-pattern and variadic .with() calls Each has a non-exhaustive variant simulating the mid-edit state, with the missing-case error silenced via @ts-expect-error so the project still type-checks. Run with `npm run perf:compile-time`, or per file with tsc --extendedDiagnostics (see the README). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gwkline
marked this pull request as ready for review
July 25, 2026 18:15
Author
|
Hi @gvergnaud, would love to get your thoughts on this 😄 I also don't want to be submitting a "slop" PR, so please let me know if there's any other testing or benchmarking you'd like me to perform |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
LLM Use Disclaimer
I used both Fable 5 and Opus 5 to generate the code in this PR. I am largely unfamiliar with this codebase, but am hoping the test-suite and benchmarks are battle-tested enough to give some sense of safety :) the diff is large, but 1133/1222 additions are related to the benchmarks I added
Motivation
We use ts-pattern heavily in a large codebase and found that
.exhaustive()matches were a significant driver of type-checking time, to the point where we started converting hot matches back toifchains with a helper to assert the remaining values arenever. Profiling with--extendedDiagnosticsand--generateTraceshowed a few dominant cost centers, addressed here with three semantics-preserving commits.Benchmarks
The two workloads are included in this PR as compile-time benchmarks under
benchmarks/compile-time/(runnable withnpm run perf:compile-time, or per file withtsc --extendedDiagnostics— see its README). All numbers aretsc --extendedDiagnosticsinstantiation counts (deterministic) on TS 5.9, measured cumulatively at each commit:.with(literal, handler)and.exhaustive()— the enum/string-union case (large-union-exhaustive.ts).discriminated-union-exhaustive.ts) matched per-discriminant with single, refined ({ kind: 'document', inlined: true }) and variadic multi-pattern.with()calls, and.exhaustive().NonExhaustiveErrorreported (the-non-exhaustive.tsfiles).(percentages are relative to the previous column)
Wall-clock check time for the whole bench file: literal(200) 0.78s → 0.46s, literal(400) 1.84s → 0.57s. For the objects bench the per-expression time from
--generateTracedrops from 520ms → 196ms (−62%); whole-file time moves less because it's dominated by the one-time cost of loading the library's types.For scale: the raw-TypeScript equivalent (
if/assertUnreachablechains) is 0 instantiations. The fluent API's irreducible floor —Matchre-instantiation andExcludenarrowing per step with all pattern machinery deleted — measures at ~155k on literal(200), so the literal path is now within ~1.5× of what this API shape can theoretically do.The commits
Commit 1 — fast paths for primitive patterns in the pattern-machinery types
Each
.with()call re-instantiates the pattern machinery on the narrowed input union, even though primitive patterns can't interact with object/array/Map/Set decomposition. Adds early exits where the equivalence is provable:Pattern<a>: when the input union only contains primitives, the pattern type is justa | PatternMatcher<a>, skipping the object/array pattern computation (MergeUnion,Exclude/Extractdecomposition).InvertPatternForExclude: primitive patterns invert to themselves; skips theEqual<Pattern<i>, p>comparison, which can never be true for a primitivepsincePattern<i>always includes aPatternMatcherunion member.DeepExclude: when the excluded type only contains primitives, it can only exclude top-level members of the input union, which is exactly what nativeExcludedoes — distributing nested unions can never surface new excludable cases.ExtractPreciseValue: primitive patterns jump straight toLeastUpperBound.Commit 2 — early
neverbail-out inExtractPreciseValuefor object patternsFor a pattern like
{ type: 'a' }matched against an N-variant discriminated union,ExtractPreciseValuedistributes over the union and built the full merged object type (two mapped types, an intersection,Compute,Pick,Contains) for every member — including all non-matching members, where the result is alwaysneverbecause the discriminant key extractsnever.Since the existing
Contains<..., never>guard only ever inspected the pattern's own keys, we can compute the pattern-side mapped type first and bail out before merging in the rest of the input's properties. The resulting type is unchanged. (This costs the literal benches ~1%, which commit 3 more than recovers.)Commit 3 — dedicated
.with()overload for primitive patternsFor a primitive pattern
pthat extends the input type, the whole constraint machinery provably collapses:MatchedValue<i, InvertPattern<p, i>>=p— guaranteed by the overload'sp extends i & Primitivesconstraint viaLeastUpperBound's first branch,FindSelected<p, p>=p— primitives can't contain selections,InvertPatternForExclude<p, p>, which the commit-1 fast path resolves cheaply — preserving the rule that non-literal primitive patterns (astring-typed runtime value) don't count towards exhaustiveness.A new first overload constrained to
p extends (0 extends 1 & i ? never : i & Primitives)dispatches primitive patterns without instantiating any of that machinery. Object patterns, arrays andP.*matchers fail the constraint and fall through to the existing overloads unchanged. Theany-guard is required becauseany & Primitivesisany, which would otherwise let object patterns match this overload (caught bytests/wildcards.test.ts).Approaches tried and rejected
InvertPattern/ restructuring the general overload'svalueconstraint: every variant — even provably-equivalent ones — regressed the literal bench 2-4× by breaking conditional-type deferral during handler inference.MatchedValue<i, InvertPattern<p, i>>appears to sit at a local optimum for tsc's instantiation caching, which is why commit 3 adds a bypass overload instead of touching it.Match<...>directly from the fast overload instead of throughX extends infer excluded ? ... : never: 60% worse — the deferred-conditional wrapper is load-bearing.handledCasesin the fast overload (redundant sinceExcludealready narrows for primitives): saved <0.1%, not worth the bookkeeping divergence.FindSelectedfast path for selection-free patterns: <0.3%, not worth adding a conditional to an inference-sensitive position.Benchmarks included in this PR
The compile-time benchmarks for these optimizations are now part of the repository under
benchmarks/compile-time/:shared.ts: Reusable type definitions for the benchmarks (LargeUnionwith 200 string-literal members and a genericEntitydiscriminated union).large-union-exhaustive.tsandlarge-union-non-exhaustive.ts: Measure the literal-union optimization path.discriminated-union-exhaustive.tsanddiscriminated-union-non-exhaustive.ts: Measure the object-pattern optimization path.Run them with
npm run perf:compile-time, or measure individual files withtsc --noEmit --strict --skipLibCheck --extendedDiagnostics <file>. The-non-exhaustivevariants simulate the mid-edit state with one case missing, silenced with@ts-expect-errorso the project type-checks cleanly. Seebenchmarks/compile-time/README.mdfor details.Verification
tsc --strictoversrcis clean.npm run perfover the whole pre-existing test suite (excluding the test added by this PR from both sides) improves from 3,689,755 to 3,527,866 instantiations (−4.4%) — no regression on object/tuple/array-heavy patterns. The modest repo-wide number is expected: the suite mostly matches on small 2-5 member unions where fixed overhead dominates, while these optimizations target cost that scales with union size.NonExhaustiveError<...>with the missing cases named.string-typed runtime pattern still doesn't count toward exhaustiveness; invalid literal patterns still error;match<any, T>still dispatches object patterns to the general overload; selections/value handler params for literal patterns are unchanged; boolean/null/undefined literals and string enums behave identically. (Non-const numeric enum members widening to the enum type fails the same way on current main — pre-existing, unrelated.)@ts-expect-errornon-exhaustive case.🤖 Generated with Claude Code