Skip to content

perf: reduce type instantiations for literal and discriminated-union matches - #357

Open
gwkline wants to merge 5 commits into
gvergnaud:mainfrom
gwkline:gwkline/optimize-string-exhaustiveness
Open

perf: reduce type instantiations for literal and discriminated-union matches#357
gwkline wants to merge 5 commits into
gvergnaud:mainfrom
gwkline:gwkline/optimize-string-exhaustiveness

Conversation

@gwkline

@gwkline gwkline commented Jul 25, 2026

Copy link
Copy Markdown

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 to ifchains with a helper to assert the remaining values are never. Profiling with --extendedDiagnostics and --generateTrace showed 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 with npm run perf:compile-time, or per file with tsc --extendedDiagnostics — see its README). All numbers are tsc --extendedDiagnostics instantiation counts (deterministic) on TS 5.9, measured cumulatively at each commit:

  • literal(N): an N-member string-literal union matched member-by-member with .with(literal, handler) and .exhaustive() — the enum/string-union case (large-union-exhaustive.ts).
  • objects: a 20-variant discriminated union of wide object types (modeled on production code, discriminated-union-exhaustive.ts) matched per-discriminant with single, refined ({ kind: 'document', inlined: true }) and variadic multi-pattern .with() calls, and .exhaustive().
  • -missing variants have one case removed, i.e. the non-exhaustive state your editor is in mid-edit, with NonExhaustiveError reported (the -non-exhaustive.ts files).
bench v5.9.0 + commit 1 + commit 2 + commit 3 total
literal(200) 784,950 572,039 (−27%) 580,566 (+1%) 227,620 (−61%) −71%
literal(400) 2,466,750 1,681,739 (−32%) 1,690,266 (+1%) 424,320 (−75%) −83%
literal(200)-missing 820,849 596,472 (−27%) 604,999 (+1%) 252,411 (−58%) −69%
objects 485,250 480,064 (−1%) 315,156 (−35%) 315,396 (±0%) −35%
objects-missing 548,389 543,203 (−1%) 385,717 (−29%) 385,957 (±0%) −30%

(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 --generateTrace drops 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/assertUnreachable chains) is 0 instantiations. The fluent API's irreducible floor — Match re-instantiation and Exclude narrowing 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 just a | PatternMatcher<a>, skipping the object/array pattern computation (MergeUnion, Exclude/Extract decomposition).
  • InvertPatternForExclude: primitive patterns invert to themselves; skips the Equal<Pattern<i>, p> comparison, which can never be true for a primitive p since Pattern<i> always includes a PatternMatcher union member.
  • DeepExclude: when the excluded type only contains primitives, it can only exclude top-level members of the input union, which is exactly what native Exclude does — distributing nested unions can never surface new excludable cases.
  • ExtractPreciseValue: primitive patterns jump straight to LeastUpperBound.

Commit 2 — early never bail-out in ExtractPreciseValue for object patterns

For a pattern like { type: 'a' } matched against an N-variant discriminated union, ExtractPreciseValue distributes 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 always never because the discriminant key extracts never.

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 patterns

For a primitive pattern p that extends the input type, the whole constraint machinery provably collapses:

  • MatchedValue<i, InvertPattern<p, i>> = p — guaranteed by the overload's p extends i & Primitives constraint via LeastUpperBound's first branch,
  • FindSelected<p, p> = p — primitives can't contain selections,
  • the excluded type is InvertPatternForExclude<p, p>, which the commit-1 fast path resolves cheaply — preserving the rule that non-literal primitive patterns (a string-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 and P.* matchers fail the constraint and fall through to the existing overloads unchanged. The any-guard is required because any & Primitives is any, which would otherwise let object patterns match this overload (caught by tests/wildcards.test.ts).

Approaches tried and rejected

  • Fast paths inside InvertPattern / restructuring the general overload's value constraint: 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.
  • Returning Match<...> directly from the fast overload instead of through X extends infer excluded ? ... : never: 60% worse — the deferred-conditional wrapper is load-bearing.
  • Not growing handledCases in the fast overload (redundant since Exclude already narrows for primitives): saved <0.1%, not worth the bookkeeping divergence.
  • A FindSelected fast 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 (LargeUnion with 200 string-literal members and a generic Entity discriminated union).
  • large-union-exhaustive.ts and large-union-non-exhaustive.ts: Measure the literal-union optimization path.
  • discriminated-union-exhaustive.ts and discriminated-union-non-exhaustive.ts: Measure the object-pattern optimization path.

Run them with npm run perf:compile-time, or measure individual files with tsc --noEmit --strict --skipLibCheck --extendedDiagnostics <file>. The -non-exhaustive variants simulate the mid-edit state with one case missing, silenced with @ts-expect-error so the project type-checks cleanly. See benchmarks/compile-time/README.md for details.

Verification

  • All existing tests pass (48 suites / 454 tests, including the type-assertion suites); tsc --strict over src is clean.
  • npm run perf over 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.
  • Non-exhaustive matches still produce the same NonExhaustiveError<...> with the missing cases named.
  • Soundness spot-checks: a 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.)
  • Added a test covering exhaustive matching over a 50-member string enum and a 50-member literal union, including a @ts-expect-error non-exhaustive case.

🤖 Generated with Claude Code

gwkline and others added 2 commits July 25, 2026 11:47
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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 5 files

Re-trigger cubic

@gwkline
gwkline marked this pull request as draft July 25, 2026 16:06
gwkline and others added 2 commits July 25, 2026 12:11
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
gwkline force-pushed the gwkline/optimize-string-exhaustiveness branch from b4b35ad to 8bc1444 Compare July 25, 2026 16:31
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
gwkline marked this pull request as ready for review July 25, 2026 18:15
@gwkline

gwkline commented Jul 25, 2026

Copy link
Copy Markdown
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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 14 files

Re-trigger cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant