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 neitherimport
norrequire
.