Your Web News in One Place

Help Webnuz

Referal links:

Sign up for GreenGeeks web hosting
October 3, 2019 10:32 am GMT

Matching your way to consistent states

In the frontend team at HousingAnywhere we've been writing our React and Node codebases with TypeScript since the end of 2017. We have React powered applications and a few Node services for server side rendering and dynamic content routing. As our codebase was growing and more and more code was reused across different pages and modules, we decided we could benefit from having a compiler guiding us. Since then, we've been migrating one file or module at a time. Most of our new code is written in TypeScript and most of the codebase has been migrated as well.

Since then we've been looking for ways to leverage the type system to help us make sure our code is correct.

One of the things we realized is that we aren't writing JavaScript anymore. TypeScript is a different language. Yes, most of the semantics of the language are the same and the syntax as well (excluding the types). This is the goal after all, to build a superset of JavaScript. But it also has a compiler that can guide and helps us write correct code (or at least attempt to do so) which means we can now model many of our problems using types.

This realization wasn't an 'Aha' moment but more of a series of changes we went through, and patterns we adopted that enabled us to benefit more from the type system. I want to share one of those patterns with you in this blog.

Time for Modeling States

When coding, its very often the case that we have to handle several variants as part of the control flow of our applications. Be it the status of a request, the state of a component and what needs to be rendered, or the items that need to be displayed for a user to choose from.

In this article, Ill explain how you can model the status of a request and decide what outcome you want to show to the user as a result. The patterns discussed here can be applied to any other case where only one boolean isn't enough. Ill walk you through different ways to approach this. From the more traditional imperative style, to a more declarative one that leverages the type system which makes it safer.

In our status we have four potential scenarios, which we call LoadingStatus: an initial one, before any request is made. A loading one, while we are waiting for the request to be fulfilled. And lastly the success or error cases.

One approach we can take is to use a combination of boolean properties to distinguish between them. This is a very common approach to modeling such statuses and is often the default approach people use. Our first version of LoadingState looks like this:

interface LoadingState<T> {  isLoading: boolean;  isLoaded: boolean;  error?: string;  data?: T;}

As I mentioned before, there are several valid combinations possible.

const notAsked = {  isLoading: false,  isLoaded: false,  error: undefined,  data: undefined,};
Before any request is made.
const loading = {  isLoading: true,  isLoaded: false,  error: undefined,  data: undefined,};
While the request is pending.
const success = {  isLoading: false,  isLoaded: true,  error: undefined,  data: someData,};
A successful resolution
const failure = {  isLoading: false,  isLoaded: true,  error: "Fetch error",  data: undefined,};
A failed resolution

We can use that loading state in a React component to show the status and data to the user.

const MyComponent = ({loadingState}) => {  if (loadingState.isLoading) {    return <Spinner />;  }  if (loadingState.error) {    return <Alert type="danger">There was an error</Alert>;  }  if (loadingState.isLoaded) {    return <Content data={loadingState.data} />;  }  return <EmptyContent />;};

This works well for all the valid states. Now, what if our state looks like this? What should be displayed to the user?

const what = {  isLoading: true,  isLoaded: true,  error: "Fetch error",  data: someData,};

Something clearly went wrong for us to get into this state. An option could be to accept that "this is how it is: data can be inconsistent" and write tests to prevent our code from getting to this inconsistent state. Another approach would be to assume that an inconsistent state is another kind of error and write code that checks state for inconsistencies, and show an error to the user when this happens.

But wait a minute. Since our code is now statically typed, is there something that could be done to solve this problem?

Ill go through a series of improvements showing the potential and more generic ones for you to consider, and end with the version I would actually recommend using. Disclaimer: these patterns are not very common and could require some effort to get your team on board. That said, I believe youll find that theyll be worth the extra effort (in my opinion, its not that much extra effort anyway).

Here we go:

Matching cases: One property to decide them all

For the first case well start with the most generic pattern. Since all the cases are mutually exclusive (e.g. there shouldnt be data and an error at the same time), one step in the right direction would be to define all the cases as a union type.

type Status =  | "not_asked"  | "loading"  | "success"  | "failure";interface LoadingState<T> {  status: Status;  data?: T;  error?: string;}

Now that we have one value as the source of truth of our status, we can update our component.

const MyComponent = ({loadingState}) => {  if (loadingState.status === "not_asked") {    return <EmptyContent />;  }  if (loadingState.status === "loading") {    return <Spinner />;  }  if (loadingState.status === "failure") {    return (      <Alert type="danger">        {loadingState.error || "There was an error"}      </Alert>    );  }  if (laodingState.status === "success") {    return loadingState.data ? (      <Content data={loadingState.data} />    ) : null;  }};

Much better already. We (almost) solved the inconsistency problem as our status is defined by only one value now. Before we get into why I say we almost solved the problem, lets tidy things up a bit. One of the goals I mentioned at the beginning was to make our code more declarative. However, if statements are by definition imperative. If we take a step back and consider what were doing: were trying to match each variant in a way so it can handle that specific case. This can be translated into a very short and simple, yet powerful utility function.

type Matcher<Keys extends string, R> = {  [K in Keys]: (k: K) => R;};const match = <Keys extends string, R = void>(  m: Matcher<Keys, R>,) => (k: Keys) => m[k](k);

If we remove the types were left with a function that takes an object and returns a function that takes a string, and uses that string to lookup a property of the object and call it with the string. Yeah its even longer in English than it is in code.

const match = (m) => (k) => m[t](k);match({foo: () => "bar"})("foo"); // => 'bar'

The implementation is simple and doesnt say much, so lets take a look at the types. Were providing the keys of the object as type of parameter. It has to extend the string, meaning it can be an enum or a union type of strings. This guarantees that the object must have all the keys defined, providing an exhaustiveness check (i.e. the compiler gives an error if one of the properties is missing).

Now, we can update our code once more.

const MyComponent = ({loadingState}) => (  match < Status,  React.ReactNode >    {      not_asked: () => <EmptyContent />,      loading: () => <Spinner />,      success: () =>        loadingState.data ? (          <Content data={loadingState.data} />        ) : null,      failure: () => (        <Alert type="danger">          {loadingState.error || "There was an error"}        </Alert>      ),    }(loadingState.status));

This makes things more declarative. Much better! The compiler will remind us if we forget to handle one of the cases, very convenient.

compiler error on missing property

Beside making our code more declarative by expressing what each case should return instead of explicitly defining how each case should be determined, were also making the code easier to update in the future. As we add more cases to your union type, we don't have to search all over the place for code that needs updating. The compiler will simply inform us and we can be sure that every case is covered.

At HousingAnywhere, we like this pattern a lot and not only use it for React components, but basically everywhere in our code (reducers, thunks, services, etc). Even though it is a simple and short module weve made it available as a package: @housinganywhere/match.

Fine Tuning: One last match

Weve already made big improvements compared to the initial approach and it scales very well thanks to the exhaustiveness check.

But as you might have seen in the previous examples, we still have to check the success and failure cases for undefined values. Why do we want to avoid this? Because we still have the chance to end up with inconsistent states that make no sense.

const leWhat = {  status: "not_asked",  data: someData,  error: "Oops this makes no sense!",};const queEsEsto = {  status: "success",  data: undefined,  error: undefined,};

What we really want is for the compiler to guarantee us that we can only have consistent states. As mentioned in the beginning, when we consume the LoadingState were basically matching each of the cases to handle them. We should, somehow, not only match the status but instead the whole shape of our state.

Enter discriminated unions (also known as tagged unions or algebraic data types).

A discriminated union consist of a union of all of the cases, where each case should have a common, singleton type property, the discriminant or tag. By checking that discriminant in our code with type guards, the compiler is able to understand which case we are matching.

Lets define our LoadingState once more, as a discriminated union this time.

type LoadingState<T> =  | {status: "not_asked"}  | {status: "loading"}  | {status: "success"; data: T}  | {status: "failure"; error: string};

Now we can implement a version of match that is tailored specifically for this version of LoadingState.

type LoadingStateMatcher<Data, R> = {  not_asked: () => R;  loading: () => R;  success: (data: Data) => R;  failure: (err: string) => R;};const match = <Data, R = void>(  m: LoadingStateMatcher<Data, R>,) => (ls: LoadingState<Data>) => {  if (ls.status === "not_asked") {    return m.not_asked();  }  if (ls.status === "loading") {    return m.loading();  }  if (ls.status === "success") {    return m.success(ls.data);  }  return m.failure(ls.error);};

Again, we define a matcher object with methods for each of the cases of the status. But this time, each method has a different signature and will be called with the data or error when it should. Checking the discriminant (status property) allows the compiler to understand the case we are in.

refined cases

The compiler knows data is defined when status is 'success

We will update the usage one more time with the final version of our match utility.

const MyComponent = ({loadingState}) =>  match<SomeData, React.ReactNode>({    not_asked: () => <EmptyContent />,    loading: () => <Spinner />,    success: (data) => <Content data={data} />,    failure: (err) => <Alert type="danger">{err}</Alert>,  })(loadingState.status);

Not only have we made our handling of the status more declarative and concise, but its also much safer as we cant get into those weird inconsistent states without the compiler screaming gently telling us we did something wrong.

Although these benefits may not seem like a big deal, they are quite significant. The whole point of a type system is not to have yet another source of errors to look at when we make a mistake in our code. Instead, its function is to help us write safer code, by modeling the code in such a way that we can identify its correct at compile time.

Further reading

If you want to see this pattern to model other problems, take a look at these two examples:

The ideas I present here arent new or exclusive to TypeScript. To learn more about them, you can take a look at how pattern matching (i.e. matching discriminated unions and data shapes) is implemented natively in other languages, and how type driven development can be leveraged to write code that cannot be in inconsistent states.

Happy and safe coding!


Original Link: https://dev.to/housinganywhere/matching-your-way-to-consistent-states-1oag

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