DEV Community

Cover image for Discriminated Union in React - Avoid giving boolean flags to a component with TypeScript
Kei95
Kei95

Posted on

Discriminated Union in React - Avoid giving boolean flags to a component with TypeScript

Back in 2011, Martin Fawler has published his blog post about flag argument as an anti pattern. It surely is a good read and I refer to it quite often in my personal/professional projects even now. To (over) summarize, the point of the post is; It's generally a good idea to avoid using a flag argument. Instead, you should split the function/method based on its concern. It’s still relevant now and applicable to React’s components.

The problem

Imagine we want to create a text input component that has the following three state;

1. default  - the user can interact with the input
2. error    - error message shows up and the component's label becomes red
3. disabled - the input is not interactable
Enter fullscreen mode Exit fullscreen mode

According to the states we defined above, the most intuitive way to implement them in the component is by adding two flags: isDisabled and isError. Therefore, the component would look like the following:

type InputProps = {
  label: string;
  errorMessage: string;
  isDisabled: boolean;
  isError: boolean;
  onChange: (str: string) => void;
};

export function Input({
  label,
  errorMessage,
  isDisabled,
  isError
  onChange,
}: InputProps) {
  const textColor = isError ? "#FF0000" : "#000000";

  return (
    <div>
      <label
        style={{
          display: "block",
          fontWeight: "bold",
          color: textColor
        }}
      >
        {label}
      </label>

      <input
        onChange={(event) => onChange(event.target.value)}
        disabled={isDisabled}
      />

      {isError ? (
        <span
          style={{
            display: "block",
            alignSelf: "start",
            color: textColor
          }}
        >
          {errorMessage}
        </span>
      ) : null}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you might have realized already, though we want only three possibilities out of these two flags, we accidentally got four possible combinations of flag.

What we want
1. default
2. error
3. disabled

What we got
1. default  - when both `isDisabled` and `isError` are `false`
2. error    - when only `isError` is `true`
3. disabled - when only `isDisabled` is `true`
4. ?        - when both `isError` and `isDisabled` are `true`
Enter fullscreen mode Exit fullscreen mode

Using only two flags introduces an implicit case that the component must handle to avoid unexpected behavior. If the component accepts more flags, we inevitably need to treat more potential surprises like this. For example, if you have four flags to represent 4 states, you’ll end up with 16 combinations! It’s absolutely not enjoyable to consider all the possible combinations in your component, and doing so increases the likelihood of unintentionally introducing unwanted behavior (a.k.a: A bug).

What is an ideal approach?

We now know that adding flags as properties of a component is not an ideal solution. The question then arises: "What is an alternative?" The answer I would like to offer in this post is the Discriminated Union. In short, a Discriminated Union is a common technique for working with unions. It involves having a single field that uses literal types, which TypeScript can use to narrow down the possible current type. An example of this technique is shown below:

type Dog = {
    kind: "dog";
    bark: () => void;
}

type Cat = {
    kind: "cat";
    meow: () => void;
}

type Pet = Cat | Dog;
Enter fullscreen mode Exit fullscreen mode

Back to the input component example, you can now establish a 1:1 relationship with each state using this technique:

// Create discriminated union for each possible states
export type InputState =
  | {
      type: "ERROR";
      message: string;
    }
  | {
      type: "DISABLED";
    }
  | {
      type: "DEFAULT";
    };

// Since you don't have flags anymore, we have much less props
type InputProps = {
  label: string;
  onChange: (str: string) => void;
  state: InputState;
};

export function Input({ label, onChange, state }: InputProps) {
  const textColor = state.type === "ERROR" ? "#FF0000" : "#000000";

  return (
    <div style={{ display: "flex", flexDirection: "column", width: "150px" }}>
      <label
        style={{
          fontWeight: "bold",
          color: textColor
        }}
      >
        {label}
      </label>

      <input
        onChange={(event) => onChange(event.target.value)}
        disabled={state.type === "DISABLED"}
      />

      {state.type === "ERROR" ? (
        <span style={{ color: textColor }}>{state.message}</span>
      ) : null}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

With the implementation above, you won't have to deal with any implicit "surprises". This allows us to focus on the intended cases that need to be included.

What we want
1. default
2. error
3. disabled

What we got
1. default  - when `DEFAULT` is given to `state` props
2. error    - when `ERROR` is given to `state` props
3. disabled - when `DISABLED` is given to `state` props
Enter fullscreen mode Exit fullscreen mode

Consumer with Discriminated Union pattern

Now that we saw the component with discriminated pattern, let’s see how consumer would have the component inside.

import { useState } from "react";

import { Input, InputState } from "./Input";
import "./styles.css";

// Function to update the state of input
// Discriminated union pattern forced us to create a central place to treat it.
function getInputState(input: string, isButtonDisabled: boolean): InputState {
  if (isButtonDisabled) {
    return {
      state: "DISABLED"
    };
  }

  if (input.length > 3) {
    return {
      state: "ERROR",
      message: "Too long!"
    };
  }

  return {
    state: "DEFAULT"
  };
}

export default function App() {
  // You can also abstract these lines as a custom hook if you wanted
  const [input, setInput] = useState("");
  const [isButtonDisabled, setIsButtonDisabled] = useState(false);
  getInputState(input, isButtonDisabled);

  return (
    <div className="App">
      <Input
        label={"Lorem Ipsum"}
        state={getInputState(input, isButtonDisabled)}
        onChange={(str: string) => setInput(str)}
      />

      <button onClick={() => setIsButtonDisabled(!isButtonDisabled)}>
        Disable
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Since we are changing the input's state based on a single object, it is much easier to couple related logic. In the example above, we created a function called getInputState() to return the current state of the input. As a developer working on this file for the first time, it is easier to understand how each state is determined on this screen by looking at this function.

Implementing the code with flags would have scattered the logic throughout, making it harder for other developers to understand or keep track of what is happening in the codebase.

Summary

Now that we have seen the discriminated union approach for component states, let's summarize its pros and cons compared to the flag pattern.

Pros

  • There is less chance of introducing unintended behavior.
  • The logic can be more explicit compared to a flag pattern.

Cons

  • The consumer needs to know each state, which is relatively more complicated than using a flag.
  • It requires more lines of code to implement.

Taking these factors into consideration, I still believe that following the discriminated union pattern is a good idea. The value it brings is much greater than any negative effects of cons. With that said, I conclude this article. Thank you for reading!

For whom interested in the working example, here is the CodeSandbox

Top comments (0)