Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
March 15, 2021 08:20 am GMT

Bringing Pattern Matching to TypeScript Introducing TS-Pattern v3.0

In the past few years, frontend development has become increasingly declarative. React shifted our mindsets from imperatively manipulating the DOM to declaratively expressing what the DOM should look like for a given state. It has been widely adopted by the industry, and now that we have realized how much easier it is to reason about declarative code and how many bugs are ruled out by embracing this paradigm, there is simply no going back.

Its not only the user interface state management libraries as well have been taking a declarative turn. Libraries such as XState, Redux, and many more let you declaratively manage your application state to unlock the same benefits: writing code that is easier to understand, modify and test. Today, we truly live in a declarative programming world!

Yet, Javascript and TypeScript werent designed for this paradigm, and these languages are lacking a very important piece of the puzzle: declarative code branching.

Declarative programming essentially consists of defining expressions rather than statements that is, code that evaluates to a value. The big idea is to separate the code describing what needs to be done from the code that interprets this description in order to produce side effects. For instance, making a React app essentially consists of describing how the DOM should look using JSX, and letting React mutate the DOM in a performant way under the hood.

The problem with if, else and switch

If you have used React, you probably noticed that code branching inside JSX isnt straightforward. The only way to use the if, else or switch statements we are used to is in self invoked functions (also called Immediately Invoked Function Expressions or IIFE for short):

declare let fetchState:  | { status: "loading" }  | { status: "success"; data: string }  | { status: "error" };<div>  {    (() => {      switch (fetchState.status) {        case "loading":          return <p>Loading...</p>;        case "success":          return <p>{fetchState.data}</p>;        case "error":          return <p>Oops, an error occured</p>;      }    })() // Immediately invoke the function  }</div>;

That's a lot of boilerplate and it doesn't look very nice. We can't blame React for this it's just that imperative statements like if, else and switch (which do not return any value) do not fit well in a declarative context. We need expressions instead.

JavaScript does have a way to write code branching expressions: ternaries. But they have several limitations...

Ternaries are not enough

Ternaries are a concise way of returning two different values based on a boolean:

bool ? valueIfTrue : valueIfFalse;

The simple fact that ternaries are expressions makes them the de facto way of writing code branches in React. Here's what most of our components look like nowadays:

const SomeComponent = ({ fetchState }: Props) => (  <div>    {fetchState.status === "loading" ? (      <p>Loading...</p>    ) : fetchState.status === "success" ? (      <p>{fetchState.data}</p>    ) : fetchState.status === "error" ? (      <p>Oops, an error occured</p>    ) : null}  </div>);

Nested ternaries. They are a bit hard to read, but we just don't have any better option. What if we want to define and reuse a variable inside one of our branches? This seems pretty basic, but there is no straightforward way to do that with ternaries. What if we dont want a default case and we just want to make sure we're handling all possible cases? This is called exhaustiveness checking, and guess what: we cant do that with ternaries either.

The status quo of exhaustiveness checking

There are workarounds to make TypeScript check that a switch statement is exhaustive. One of them is to call a function that takes a parameter with the never type:

// This function is just a way to tell TypeScript that this code// should never be executed.function safeGuard(arg: never) {}switch (fetchState.status) {  case "loading":    return <p>Loading...</p>;  case "success":    return <p>{fetchState.data}</p>;  case "error":    return <p>Oops, an error occured</p>;  default:    safeGuard(fetchState.status);}

This will only type-check if status has type never, which means that all possible cases are handled. This looks like a good solution, but if we want to do that in our JSX, we are back to an IIFE:

<div>  {(() => {    switch (fetchState.status) {      case "loading":        return <p>Loading...</p>;      case "success":        return <p>{fetchState.data}</p>;      case "error":        return <p>Oops, an error occured</p>;      default:        safeGuard(fetchState.status);    }  })()}</div>

Even more boilerplate.

What if we want to branch based on two values instead of one? Let's say we want to write a state reducer. It's considered good practice to branch both on the current state and on the action to prevent invalid state changes. The only option we have to ensure we are handling every case is to nest several switch statements:

type State =  | { status: "idle" }  | { status: "loading"; startTime: number }  | { status: "success"; data: string }  | { status: "error"; error: Error };type Action =  | { type: "fetch" }  | { type: "success"; data: string }  | { type: "error"; error: Error }  | { type: "cancel" };const reducer = (state: State, action: Action): State => {  switch (state.status) {    case "loading": {      switch (action.type) {        case "success": {          return {            status: "success",            data: action.data,          };        }        case "error": {          return {            status: "error",            error: action.error,          };        }        case "cancel": {          // only cancel if the request was sent less than 2 sec ago.          if (state.startTime + 2000 < Date.now()) {            return {              status: "idle",            };          } else {            return state;          }        }        default: {          return state;        }      }    }    default:      switch (action.type) {        case "fetch": {          return {            status: "loading",            startTime: Date.now(),          };        }        default: {          return state;        }      }      safeGuard(state.status);      safeGuard(action.type);  }};

Even though this is safer, its a lot of code and it's very tempting to go for the shorter, unsafe alternative: only switching on the action.

There must be a better way to do this?

Of course there is. Once more, we need to turn our gaze to functional programming languages, and see how they have been doing it all this time: Pattern Matching.

Pattern Matching is a feature implemented in many languages like Haskell, OCaml, Erlang, Rust, Swift, Elixir, Rescript The list goes on. There is even a TC39 proposal from 2017 to add Pattern Matching to the EcmaScript specification (defining the JavaScript syntax and semantic). The proposed syntax looks like this:

// Experimental EcmaScript pattern matching syntax (as of March 2021)case (fetchState) {  when { status: "loading" } -> <p>Loading...</p>,  when { status: "success", data } -> <p>{data}</p>,  when { status: "error" } -> <p>Oops, an error occured</p>}

The Pattern Matching expression starts with the case keyword followed by the value we want to branch on. Each code branch starts with a when keyword followed by the pattern: the shape our value must match for this branch to be executed. If you known about destructuring assignement this should feel pretty familiar.

Here is how the previous reducer example would look with the proposal:

// Experimental EcmaScript pattern matching syntax (as of March 2021)const reducer = (state: State, action: Action): State => {  return case ([state, action]) {    when [{ status: 'loading' }, { type: 'success', data }] -> ({      status: 'success',      data,    }),    when [{ status: 'loading' }, { type: 'error', error }] -> ({      status: 'error',      error,    }),    when [state, { type: 'fetch' }] if (state.status !== 'loading') -> ({      status: 'loading',      startTime: Date.now(),    }),    when [{ status: 'loading', startTime }, { type: 'cancel' }] if (startTime + 2000 < Date.now()), -> ({      status: 'idle',    })    when _ -> state  }};

So much better!

I didn't run any scientific study on this, but I believe that pattern matching takes advantage of our brain's natural ability for pattern recognition. A pattern looks like the shape of the value we want to match on, which makes the code much easier to read than a bunch of ifs and elses. It's also shorter and, most importantly, it's an expression!

Im very excited about this proposal, but its still in stage 1 and it is unlikely to be implemented for at least several years (if ever).

Bringing Pattern matching to TypeScript

A year ago, I started working on what was then an experimental library implementing pattern matching for TypeScript: ts-pattern. At first, I didnt expect that it would be possible to implement in userland something even close to native language support in terms of usability and type safety. It turns out I was wrong. After several months of work I realized that TypeScripts type system was powerful enough to implement a pattern matching library with all the bells and whistles we can expect from native language support.

Today, Im releasing the version 3.0 of ts-pattern

Here is the same reducer written with ts-pattern:

import { match, select, when, not, __ } from 'ts-pattern';const reducer = (state: State, action: Action) =>  match<[State, Action], State>([state, action])    .with([{ status: 'loading' }, { type: 'success', data: select() }], data => ({      status: 'success',      data,    }))    .with([{ status: 'loading' }, { type: 'error', error: select() }], error => ({      status: 'error',      error,    }))    .with([{ status: not('loading') }, { type: 'fetch' }], () => ({      status: 'loading',      startTime: Date.now(),    }))    .with([{ status: 'loading', startTime: when(t => t + 2000 < Date.now()) }, { type: 'fetch' }], () => ({      status: 'idle',    }))    .with(__, () => state) // `__` is the catch-all pattern.    .exhaustive();`

Perfectly fits in a declarative context

ts-pattern works in any (TypeScript) environment and with any framework or technology. Here is the React component example from earlier:

declare let fetchState:  | { status: "loading" }  | { status: "success"; data: string }  | { status: "error" };<div>  {match(fetchState)    .with({ status: "loading" }, () => <p>Loading...</p>)    .with({ status: "success" }, ({ data }) => <p>{data}</p>)    .with({ status: "error" }, () => <p>Oops, an error occured</p>)    .exhaustive()}</div>;

No need for an IIFE, a safeGuard function or nested ternaries. It fits right in your JSX.

Compatible with any data structure

Patterns can be anything: objects, arrays, tuples, Maps, Sets, nested in any possible way:

declare let x: unknown;const output = match(x)  // Literals  .with(1, (x) => ...)  .with("hello", (x) => ...)  // Supports passing several patterns:  .with(null, undefined, (x) => ...)  // Objects  .with({ x: 10, y: 10 }, (x) => ...)  .with({ position: { x: 0, y: 0} }, (x) => ...)  // Arrays  .with([{ firstName: __.string }], (x) => ...)  // Tuples  .with([1, 2, 3], (x) => ...)  // Maps  .with(new Map([["key", "value"]]), (x) => ...)  // Set  .with(new Set(["a"]), (x) => ...)  // Mixed & nested  .with(    [      { type: "user", firstName: "Gabriel" },      { type: "post", name: "Hello World", tags: ["typescript"] }    ],    (x) => ...)   // This is equivalent to `.with(__, () => ).exhaustive();`  .otherwise(() => ...)

In addition, the type system will reject any pattern that doesn't match the input type!

Built with type safety and type inference in mind

For every .with(pattern, handler) clause, the input value is piped to the handler function with a type narrowed down to what the pattern matches.

type Action =  | { type: "fetch" }  | { type: "success"; data: string }  | { type: "error"; error: Error }  | { type: "cancel" };match<Action>(action)  .with({ type: "success" }, (matchedAction) => {    /* matchedAction: { type: 'success'; data: string } */  })  .with({ type: "error" }, (matchedAction) => {    /* matchedAction: { type: 'error'; error: Error } */  })  .otherwise(() => {    /* ... */  });

Exhaustiveness checking support

ts-pattern nudges you towards safer code by making exhaustive matching the default:

type Action =  | { type: 'fetch' }  | { type: 'success'; data: string }  | { type: 'error'; error: Error }  | { type: 'cancel' };return match(action)  .with({ type: 'fetch' }, () => /* ... */)  .with({ type: 'success' }, () => /* ... */)  .with({ type: 'error' }, () => /* ... */)  .with({ type: 'cancel' }, () => /* ... */)  .exhaustive(); // This compilesreturn match(action)  .with({ type: 'fetch' }, () => /* ... */)  .with({ type: 'success' }, () => /* ... */)  .with({ type: 'error' }, () => /* ... */)  // This doesn't compile!  // It throws a `NonExhaustiveError<{ type: 'cancel' }>` compilation error.  .exhaustive();

You can still opt-out by using .run() instead of .exhaustive() if you really need to:

return match(action)  .with({ type: 'fetch' }, () => /* ... */)  .with({ type: 'success' }, () => /* ... */)  .with({ type: 'error' }, () => /* ... */)  .run(); //  This is unsafe but it compiles

Wildcards

If you need a pattern to always match, you can use the __ (wildcard) pattern. This is a pattern that matches anything:

import { match, __ } from 'ts-pattern';match([state, event])  .with(__, () => state)  // You can also use it inside another pattern:  .with([__, { type: 'success' }], ([_, event]) => /* event: { type: 'success', data: string } */)  // at any level:  .with([__, { type: __ }], () => state)  .exhaustive();

It's also possible to match a specific type of input with __.string, __.boolean and __.number. It's especially useful when dealing with unknown values, maybe coming from an API endpoint:

import { match, __ } from "ts-pattern";type Option<T> = { kind: "some"; value: T } | { kind: "none" };type User = { firstName: string; age: number; isNice: boolean };declare let apiResponse: unknown;const maybeUser = match<unknown, Option<User>>(apiResponse)  .with({ firstName: __.string, age: __.number, isNice: __.boolean }, (user) =>    /* user: { firstName: string, age: number, isNice: boolean} */    ({ kind: "some", value: user })  )  .otherwise(() => ({ kind: "none" }));// maybeUser: Option<User>

When clauses

You can use the when helper function to make sure the input respects a guard function:

import { match, when, __ } from 'ts-pattern';const isOdd = (x: number) => Boolean(x % 2)match({ x: 2 })  .with({ x: when(isOdd) }, ({ x}) => /* `x` is odd */)  .with(__, ({ x }) => /* `x` is even */)  .exhaustive();

You can also call .with() with a guard function as second parameter:

declare let input: number | string;match(input)  .with(__.number, isOdd, (x) => /* `x` is an odd number */)  .with(__.string, (x) => /* `x` is a string */)  // Doesn't compile! the even number case is missing.  .exhaustive();

Or just use .when():

match(input)  .when(isOdd, (x) => /* ... */)  .otherwise(() => /* ... */);

Property selection

When matching on a deeply nested input, it's often nice to extract pieces of the input to use in the handlers and avoid having to separately destructure the input. The select helper function enables you to do that:

import { match, select } from "ts-pattern";type input =  | { type: "text"; content: string }  | { type: "video"; content: { src: string; type: string } };match(input)  // Anonymous selections are directly passed as first parameter:  .with(    { type: "text", content: select() },    (content) => <p>{content}</p> /* content: string */  )  // Named selections are passed in a `selections` object:  .with(    { type: "video", content: { src: select("src"), type: select("type") } },    ({ src, type }) => (      <video>        <source src={src} type={type} />      </video>    )  )  .exhaustive();

Tiny

Since this library is mostly type-level code, it has a tiny bundle footprint: only 1.6kB once minified and gzipped!

Drawbacks

For the type inference and exhaustiveness checking to work properly, ts-pattern relies on type level computations that might slow down the typechecking of your project. I tried (and will continue to try) to make it as fast as possible, but it will always be slower than a switch statement. Using ts-pattern, means trading some compilation time for type safety and for code that is easier to maintain. If this trade-off doesn't appeal to you, that's ok! You don't have to use it!

Installation

You can install it from npm

npm install ts-pattern

Or yarn

yarn add ts-pattern

Conclusion

I love tools which make it easy to write better code. I was heavily inspired by ImmutableJS and Immer in that regard. Simply by providing a nicer API to manipulate immutable data structures, these libraries greatly encouraged the adoption of immutability in the industry.

Pattern matching is great because it nudges us towards writing code that is safer and more readable, and ts-pattern is my humble attempt to popularise this concept in the TypeScript community. ts-pattern v3.0 is the first LTS version. Now that the technical challenges are solved, this version focuses on performance and usability. I hope you will enjoy it.

Star it on GitHub if you think its exciting!

You can find the full API reference on the ts-pattern repository

I posted the link on Hacker News don't hesitate to post a comment in the thread if you have any question, I'll try to answer to everyone!

PS: Shouldn't we just switch to languages supporting pattern matching, like Rescript?

I personally think we should! Rescript looks like a very promising language, and I would definitely pick it as my language of choice if I were to start a new project in 2021. We don't always have the luxury of starting a new project from scratch though, and the TypeScript code we write could benefit a lot from adopting pattern matching. Mine certainly would. I hope you found my case convincing

PPS: Inspiration

This library was heavily inspired by the great article Pattern Matching in TypeScript with Record and Wildcard Patterns by Wim Jongeneel. Read it if you want to have a rough idea of how ts-pattern works under the hood.

Cya!


Original Link: https://dev.to/gvergnaud/bringing-pattern-matching-to-typescript-introducing-ts-pattern-v3-0-o1k

Share this article:    Share on Facebook
View Full Article

Dev To

An online community for sharing and discovering great ideas, having debates, and making friends

More About this Source Visit Dev To