github gvergnaud/ts-pattern v4.1.2

latest releases: v5.1.1, v5.1.0, v5.0.8...
16 months ago

Make subsequent .with clause inherit narrowing from previous clauses

Problem

With the current version of ts-pattern, nothing prevents you from writing .with clauses that will never match any input because the case has already been handled in a previous close:

type Plan = 'free' | 'pro' | 'premium';

const welcome = (plan: Plan) => 
  match(plan)
    .with('free', () => 'Hello free user!')
    .with('pro', () => 'Hello pro user!')
    .with('pro', () => 'Hello awesome user!')
    //      👆 This will never match!
    //         We should exclude "pro"
    //         from the input type to 
    //         reject duplicated with clauses. 
    .with('premium', () => 'Hello premium user!')
    .exhaustive()

Approach

Initially, I was reluctant to narrow the input type on every call of .with because of type checking performance. TS-Pattern's exhaustive checking is pretty expensive because it not only narrows top-level union types, but also nested ones. In order to make that work, TS-Pattern needs to distribute nested union types when they are matched by a pattern, which can sometimes generate large unions which are more expensive to match.

I ended up settling on a more modest approach, which turns out to have great performance: Only narrowing top level union types. This should cover 80% of cases, including the aforementioned one:

type Plan = 'free' | 'pro' | 'premium';

const welcome = (plan: Plan) => 
  match(plan)
    .with('free', () => 'Hello free user!')
    .with('pro', () => 'Hello pro user!')
    .with('pro', () => 'Hello awesome user!')
    //       ^ ❌ Does not type-check in TS-Pattern v4.1!
    .with('premium', () => 'Hello premium user!')
    .exhaustive()

Examples of invalid cases that no longer type check:

Narrowing will work on unions of literals, but also discriminated unions of objects:

type Entity =
  | { type: 'user', name: string }
  | { type: 'org', id: string };

const f = (entity: Entity) => 
  match(entity)
    .with({ type: 'user' }, () => 'user!')
    .with({ type: 'user' }, () => 'user!')
    //                   ^ ❌ Does not type-check in TS-Pattern v4.1!
    .with({ type: 'org' }, () => 'org!')
    .exhaustive()

It also works with tuples, and any other union of data structures:

type Entity =
  | [type: 'user', name: string]
  | [type: 'org', id: string]

const f = (entity: Entity) => 
  match(entity)
    .with(['user', P.any], () => 'user!')
    .with(['user', P.any], () => 'user!')
    //      ^ ❌ Does not type-check in TS-Pattern v4.1!
    .with(['org', P.any], () => 'org!')
    .exhaustive()

It works with any patterns, including wildcards:

type Entity =
  | [type: 'user', name: string]
  | [type: 'org', id: string]

const f = (entity: Entity) => 
  match(entity)
    .with(P.any, () => 'user!') // catch all
    .with(['user', P.any], () => 'user!')
    //      ^ ❌ Does not type-check in TS-Pattern v4.1!
    .with(['org', P.any], () => 'org!')
    //      ^ ❌ Does not type-check in TS-Pattern v4.1!
    .exhaustive()

Examples of invalid cases that still type check:

This won't prevent you from writing duplicated clauses in case the union you're matching is nested:

type Plan = 'free' | 'pro' | 'premium';
type Role = 'viewer' | 'contributor' | 'admin';

const f = (plan: Plan, role: Role) => 
  match([plan, role] as const)
    .with(['free', 'admin'], () => 'free admin')
    .with(['pro', P.any], () => 'all pros')
    .with(['pro', 'admin'], () => 'admin pro')
    //            ^ this unfortunately still type-checks
    .otherwise(() => 'other users!')

.otherwise's input also inherit narrowing

The nice effect of refining the input value on every .with clause is that .otherwise also get a narrowed input type:

type Plan = 'free' | 'pro' | 'premium';

const welcome = (plan: Plan) => 
  match(plan)
    .with('free', () => 'Hello free user!')
    .otherwise((input) => 'pro or premium')
    //           👆 input is inferred as `'pro' | 'premium'`

Perf

Type-checking performance is generally better, with a 29% reduction of type instantiation and a 17% check time improvement on my benchmark:

description before after delta
Files 181 181 0%
Lines of Library 28073 28073 0%
Lines of Definitions 49440 49440 0%
Lines of TypeScript 11448 11516 0.59%
Nodes of Library 119644 119644 0%
Nodes of Definitions 192409 192409 0%
Nodes of TypeScript 57791 58151 0.62%
Identifiers 120063 120163 0.08%
Symbols 746269 571935 -23.36%
Types 395519 333052 -15.79%
Instantiations 3810512 2670937 -29.90%
Memory used 718758K 600076K -16.51%
Assignability cache size 339114 311641 -8.10%
Identity cache size 17071 17036 -0.20%
Subtype cache size 2759 2739 -0.72%
Strict subtype cache size 2544 1981 -22.13%
I/O Read time 0.01s 0.01s 0%
Parse time 0.28s 0.28s 0%
ResolveModule time 0.01s 0.02s 100%
ResolveTypeReference time 0.01s 0.01s 0%
Program time 0.34s 0.34s 0%
Bind time 0.13s 0.14s 7.69%
Check time 5.28s 4.37s -17.23%
Total time 5.75s 4.85s -15.65%

Other changes

  • TS-Pattern's package.json exports have been updated to provide a default export for build systems that read neither import nor require.

Don't miss a new ts-pattern release

NewReleases is sending notifications on new releases.