Skip to content

Releases: gvergnaud/ts-pattern

v4.1.2

08 Jan 21:25
Compare
Choose a tag to compare

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.

v4.0.6

16 Nov 10:04
Compare
Choose a tag to compare

Bug fixes

abstract class A {}

class B extends A {}

class C extends A {}

const object = new B() as B | C;

match(object)
    .with(P.instanceOf(A), a => ...) // a: B | C
     //                ^
     //     ✅ This type-checks now! 
    .exhaustive()

v4.0.5

16 Nov 09:58
Compare
Choose a tag to compare

This release adds the ./package.json file to exported files (PR by @zoontek).

This fixes nodejs/node#33460
Without it, it breaks require.resolve, used by a lot of tooling (Rollup, React native CLI, etc)

v4.0.4

06 Jul 13:33
Compare
Choose a tag to compare

Fixes:

  • When nesting P.array() and P.select(), the handler function used to receive undefined instead of an empty array when the input array was empty. Now it received an empty array as expected:
match([])
    .with(P.array({ name: P.select() }), (names) => names)  /* names has type `string[]` and value `[]` */
    // ...
    .exhaustive()
  • The types used to forbid using an empty array pattern ([]) when matching on a value of type unknown. This has been fixed.
const f = (x: unknown) => match(x).with([], () => "this is an empty array!").otherwise(() => "?")

Commits:

v4.0.2

17 Apr 17:16
Compare
Choose a tag to compare

Patch release containing a few runtime performance improvements:

  • Use a Builder class internally in match expression to rely on prototypal inheritance instead of defining method every time .with is called.
  • Handle the .with(pattern, handler) case separately from .with(...pattern, handler) to avoid iterating on params and make it faster.

✨ v4.0.1 ✨

26 Mar 10:53
3910835
Compare
Choose a tag to compare

⚠️ Breaking changes

Imports

type-specific wildcard patterns have moved from __.<pattern> to a new Pattern qualified module, also exported as P by ts-pattern.

- import { match, __ } from 'ts-pattern';
+ import { match, Pattern } from 'ts-pattern';


const toString = (value: string | number) =>
  match(value)
-   .with(__.string, (v) => v)
-   .with(__.number, (v) => `${v}`)
+   .with(Pattern.string, (v) => v)
+   .with(Pattern.number, (v) => `${v}`)
    .exhaustive();

or

- import { match, __ } from 'ts-pattern';
+ import { match, P } from 'ts-pattern';


const toString = (value: string | number) =>
  match(value)
-   .with(__.string, (v) => v)
-   .with(__.number, (v) => `${v}`)
+   .with(P.string, (v) => v)
+   .with(P.number, (v) => `${v}`)
    .exhaustive();

__

The top level __ export was moved to P._ and P.any:

- import { match, __ } from 'ts-pattern';
+ import { match, P } from 'ts-pattern';


const toString = (value: string | number) =>
  match(value)
-   .with(__, (v) => `${v}`)
+   .with(P._, (v) => `${v}`)
    // OR
+   .with(P.any, (v) => `${v}`)
    .exhaustive();

select(), not(), when()

Function to create patterns have been moved to the P module.

- import { match, select, not, when } from 'ts-pattern';
+ import { match, P } from 'ts-pattern';


const toString = (value: number) =>
  match(value)
-   .with({ prop: select() }, (v) => `${v}`)
+   .with({ prop: P.select() }, (v) => `${v}`)

-   .with({ prop: not(10) }, (v) => `${v}`)
+   .with({ prop: P.not(10) }, (v) => `${v}`)

-   .with({ prop: when((x) => x < 5) }, (v) => `${v}`)
+   .with({ prop: P.when((x) => x < 5) }, (v) => `${v}`)
    .exhaustive();

Pattern type

the Pattern type which used to be exported at the toplevel is now accessible at P.Pattern.

- import { match, Pattern } from 'ts-pattern';
+ import { match, P } from 'ts-pattern';

- const pattern: Pattern<number> = P.when(x => x > 2);
+ const pattern: P.Pattern<number> = P.when(x => x > 2);

list patterns

The syntax for matching on a list of elements with an unknown length has changed from [subpattern] to P.array(subpattern).

Example:

- import { match, __ } from 'ts-pattern';
+ import { match, P } from 'ts-pattern';


const parseUsers = (response: unknown) =>
  match(response)
-   .with({ data: [{ name: __.string }] }, (users) => users)
+   .with({ data: P.array({ name: P.string }) }, (users) => users)
    .otherwise(() => []);

Now [subpattern] matches arrays with 1 element in them. This is more consistent with native language features, like destructuring assignement and is overall more intuitive. This will resolve #69, #62 and #46.

NaN

The __.NaN pattern has been replaced by simply using the NaN value in the pattern:

match<number>(NaN)
-   .with(__.NaN, () => "this is not a number")
+   .with(NaN, () => "this is not a number")
    .otherwise((n) => n);

⭐️ New features ⭐️

Here is the list of all new features which have been added in TS-Pattern v4.

Arrays and unary tuples

P.array(pattern)

To match an array of elements, you can now use P.array:

import { match, P } from 'ts-pattern';

const responsePattern = {
  data: P.array({
    id: P.string,
    post: P.array({
      title: P.string,
      content: P.string,
    }),
  }),
};

fetchSomething().then((value: unknown) =>
  match(value)
    .with(responsePattern, (value) => {
      // value: { data: { id: string, post: { title: string, content: string }[] }[] }
      return value;
    })
    .otherwise(() => {
      throw new Error('unexpected response');
    })
);

Optional object properties

P.optional(pattern)

If you want one of the keys of your pattern to be optional, you can now use P.optional(subpattern).

If you P.select() something in an optional pattern, it's type will be infered as T | undefined.

import { match, P } from 'ts-pattern';

const doSomethingWithUser = (user: User | Org) =>
  match(user)
    .with(
      {
        type: 'user',
        detail: {
          bio: P.optional(P.string),
          socialLinks: P.optional({
            twitter: P.select(),
          }),
        },
      },
      (twitterLink, value) => {
        // twitterLink: string | undefined
        /**
         *  value.detail: {
         *      bio?: string,
         *      socialLinks?: {
         *          twitter: string
         *      }
         *  }
         **/
      }
    )
    .otherwise(() => {
      throw new Error('unexpected response');
    });

Union & intersection patterns

P.union(...patterns) and P.intersection(...patterns) combine several patterns into a single one, either by checking that one of them match the input (p.union) or all of them match it (P.intersection).

P.union(...patterns)

type Input =
  | { type: 'a'; value: string }
  | { type: 'b'; value: number }
  | {
      type: 'c';
      value:
        | { type: 'd'; value: boolean }
        | { type: 'e'; value: string[] }
        | { type: 'f'; value: number[] };
    };

const f = (input: Input) =>
  match(input)
    .with(
      { type: P.union('a', 'b') },
      // x: { type: 'a'; value: string } | { type: 'b'; value: number }
      (x) => 'branch 1'
    )
    .with(
      // P.union can take any subpattern:
      {
        type: 'c',
        value: { value: P.union(P.boolean, P.array(P.string)) },
      },
      (x) => 'branch 2' // x.value.value: boolean | string[]
    )
    .with({ type: 'c', value: { type: 'f' } }, () => 'branch 3')
    .exhaustive();

P.intersection(...patterns)

class A {
  constructor(public foo: 'bar' | 'baz') {}
}

class B {
  constructor(public str: string) {}
}

const f = (input: { prop: A | B }) =>
  match(input)
    .with(
      { prop: P.intersection(P.instanceOf(A), { foo: 'bar' }) },
      // prop: A & { foo: 'bar' }
      ({ prop }) => 'branch 1'
    )
    .with(
      { prop: P.intersection(P.instanceOf(A), { foo: 'baz' }) },
      // prop: A & { foo: 'baz' }
      ({ prop }) => 'branch 2'
    )
    .with(
      { prop: P.instanceOf(B) },
      // prop: B
      ({ prop }) => 'branch 3'
    )
    .exhaustive();

Select with sub pattern

P.select() now can take a subpattern and match only what the subpattern matches:

type Img = { type: 'img'; src: string };
type Text = { type: 'text'; content: string; length: number };
type User = { type: 'user'; username: string };
type Org = { type: 'org'; orgId: number };

const post = (input: { author: User | Org; content: Text | Img }) =>
  match(input)
    .with(
      { author: P.select({ type: 'user' }) },
      // user: User
      (user) => {}
    )
    .with(
      {
        // This also works with named selections
        author: P.select('org', { type: 'org' }),
        content: P.select('text', { type: 'text' }),
      },
      // org: Org, text: Text
      ({ org, text }) => {}
    )
    .otherwise(() => {
      // ...
    });

Infer the matching types from a pattern

P.infer<typeof pattern>

TS-Pattern is pretty handy for parsing unknown payloads like HTTP responses. You can write a pattern for the shape you are expecting, and then use isMatching(pattern, response) to make sure the response has the correct shape.

One limitation TS-Pattern had in its previous version was that it did not provide a way to get the TypeScript type of the value a given pattern matches. This is what P.infer<typeof pattern> does :)

const postPattern = {
  title: P.string,
  description: P.optional(P.string),
  content: P.string,
  likeCount: P.number,
};

type Post = P.infer<typeof postPattern>;
// Post: { title: string, description?: string, content: string, likeCount: number }

const userPattern = {
  name: P.string,
  postCount: P.number,
  bio: P.optional(P.string),
  posts: P.optional(P.array(postPattern)),
};

type User = P.infer<typeof userPattern>;
// User: { name: string, postCount: number, bio?: string, posts?: Post[]  }

const isUserList = isMatching(P.array(userPattern));

const res = await fetchUsers();

if (isUserList(res)) {
  // res: User
}

New type specific wildcards

P.symbol

P.symbol is a wildcard pattern matching any symbol.

match(Symbol('Hello'))
  .with(P.symbol, () => 'this is a symbol!')
  .exhaustive();

P.bigint

P.bigint is a wildcard pattern matching any bigint.

match(200n)
  .with(P.bigint, () => 'this is a bigint!')
  .exhaustive();

v3.3.5

23 Jan 20:45
Compare
Choose a tag to compare

Bug fixes

This fixes a type inference bug impacting handler functions with explicit type annotations.

It used to be possible to annotate the handler function with an invalid type annotation. Thanks to this commit, it no longer type-checks 2d75074.

See the related issue for more details: #73

v3.3.4

20 Dec 10:35
Compare
Choose a tag to compare

Bug fixes

This release fixes a type inference bug specific to Error sub classes. See the related issue for more details: #63

v3.3.2

23 Sep 20:26
Compare
Choose a tag to compare

This patch contains some compile time perf improvements.

@ahejlsberg recently implemented tail call elimination for recursive conditional types (microsoft/TypeScript#45711). This release is preparation work to take advantage of this new feature by making most type helper functions tail recursive. From the non scientific tests I made on my machine, this also improves the compilation time of the tests/ folder quite significantly on our current TS version (4.4). Compilation is ~ 20% faster.

v3.3.1

17 Sep 17:03
Compare
Choose a tag to compare

Features

Add a __.NaN pattern, matching only NaNs values. Thanks @mhintz for adding this

const res = match<number | null>(NaN)
      .with(null, () => 'null!')
      .with(__.NaN, () => 'NaN!')
      .with(__.number, (x) => 'a number!')
      .exhaustive();

console.log(res)
// => 'NaN!'

Bugfix

Update the __.number pattern to also match on NaN values.

Since NaN has type number in TypeScript, there is no way to distinguish a NaN from a regular number at the type level. This was causing an issue where .exhaustive() considered all numbers handled by the __.number pattern even though NaN wasn't matched by it, resulting in possible runtime errors.

const res = match<number | null>(NaN)
      .with(null, () => 'null!')
      .with(__.number, (x) => 'a number!')
      // This used to throw at runtime because NaN wasn't matched by __.number
      .exhaustive();

console.log(res)
// => 'a number!'