Categories
Programming

React and Pipes in TypeScript

React, TypeScript, Ramda, and Functional Programming

In the day job I recently recommended using Ramda to help clean up the readability of our UI code.

Ramda is a collection of pure functions designed to fit together using functional programming patterns.

The Context

We had a piece of TypeScript code landing that processed some data and rendered a React component.

const filteredLabels =
  data.community.labels.filter((label) => {

    if (label.type === LabelType.AutoLabel &&
        (isGroupPage === true ?
          ['system_a', 'special'] :
          ['system_a']
        ).includes(label.name)
    ) {
      return false;
    }

    if (label.type === LabelType.UserDefined &&
          label.stats.timesUsed === 0) {
      return false;
    }
    
    return true;
  });

filteredLabels.sort((labelA, labelB) => {
  if (labelA.type === LabelType.AutoLabel) {
    if (labelB.type === LabelType.UserDefined) {
      return -1;
    }
    return labelA.name.toLowerCase() < labelB.name.toLowerCase() ? -1 : 1;
  }

  if (labelB.type === LabelType.AutoLabel) {
     return 1;
  }
  return labelA.name.toLowerCase() < labelB.name.toLowerCase() ? -1 : 1;
});

return filterdLabels.map((label) => <>{/* React UI */}</>

Three distinct things are happening here:

  1. data.community.labels.filter(/* ... */) is removing certain Label instances from the list.
  2. filteredLabels.sort(/* ... */) is sorting the filtered items first by their .type then by their .name (case-insensitive)
  3. filteredLabels.map(/* ... */) is turning the list of Label instances into a JSX.Element.

The hardest part for me to decipher as a reader of the code was step two: given two labels what was the intended sort order?

After spending a few moments internalizing those if statements I came to the conclusion the two properties being used for comparison were label.type and label.name.

A label of .type === LabelType.AutoLabel should appear before a label of .type === LabelType.UserDefined.

Labels with the same .type should then be sorted by their .name case-insensitively.

Ramda’s sortWith

The problem I was encountering with this bit of code is that my human brain works this way:

Given a list of Labels:
- Sort them by their .type with .AutoLabel preceding .UserDefined
- Sort labels of the some .type by their .name case-insensitively

Ramda’s sortWith gives us an API that sounds similar in theory:

Sorts a list according to a list of comparators.

A “comparator” is typed with (a, a) => Number. My list of comparators will be one for the label.type and one for the label.name.

import { sortWith } from 'ramda';

const sortLabels = sortWith<Label>([
  // 1. compare label types
  // 2. compare label names
]);

A comparator‘s return value here is a bit ambiguous declaring Number in the documentation. But their code example for sortWith points to some more handy functions: ascend and descend.

Here’s the description for ascend:

Makes an ascending comparator function out of a function that returns a value that can be compared with < and >.

To sort by label.type I need to map the LabelType to value that will sort .AutoLabel to precede .UserDefined:

const sortLabels = sortWith<Label>([
  ascend((label) => label.type === LabelType.AutoLabel ? -1 : 1,
  // 2. compare label names
]);

To sort by the .name I can ascend with a case-insensitive value for label.name:

const sortLabels = sortWith<Label>([
  ascend((label) => label.type === LabelType.AutoLabel ? -1 : 1,
  ascend((label) => label.name.toLowercase(),
]);

Ramda is a curried API. This means by leaving out the second argument, sortLabels now has the TypeScript signature of:

type LabelSort = (labels: Label[]) => Label[]

Since we hinted the generic type on sortWith<Label>() TypeScript has also inferred that the functions we give to ascend receive a Label type as their single argument (see on TS Playground).

Screen capture of TS Playground tooltip showing Label as the inferred type.

Given Ramda’s curried interface, we can extract that sorting business logic into a reusable constant.

/**
 * Sort a list of Labels such that
 *  - AutoLabels appear before UserDefined
 *  - Labels are sorted by name case-insensitively
 */
export const sortLabelsByTypeAndName = sortWith<Label>(
  [
    ascend((label) => label.type === LabelType.AutoLabel ? -1 : 1,
    ascend((label) => label.name.toLowercase(),
  ]
);

Using this to replace the original code’s sorting we now have:

const filteredLabels =
  data.community.labels.filter((label) => {

    if (label.type === LabelType.AutoLabel &&
        (isGroupPage === true ?
          ['system_a', 'special'] :
          ['system_a']
        ).includes(label.name)
    ) {
      return false;
    }

    if (label.type === LabelType.UserDefined &&
          label.stats.timesUsed === 0) {
      return false;
    }
    return true;
  });

const sortedLabels = sortLabelsByTypeAndName(filteredLabels);

return sortedLabels((label) => <>{/* React UI */}</>);

Now let’s see what Ramda’s filter can do for us.

Declarative Filtering with filter

Ramda’s filter looks similar to Array.prototype.filter:

Filterable f => (a → Boolean) → f a → f a

Takes a predicate and a Filterable, and returns a new filterable of the same type containing the members of the given filterable which satisfy the given predicate. Filterable objects include plain objects or any object that has a filter method such as Array.

The first change will be conforming to this interface:

import { filter } from 'ramda';

const filteredLabels = filter<Label>((label) => {
  // boolean logic here
}, data.community.labels);

There are two if statements in our original filter code that both have early returns. This indicates there are two different conditions that we test for.

  • Remove Label if
    • .type is AutolLabel and
    • .name is in a list of predefined label names
  • Remove Label if
    • .type is UserDefined and
    • .stats.count is zero (or fewer)

To clear things up we can turn these into their own independent functions that capture the business logic they represent.

The AutoLabel scenario has one complication. The isGroup variable changes the behavior by changing the names the label is allowed to have.

In Lambda calculus this is called a free variable. We can solve this now by creating our own closure that accepts the string[] of names and returns the Label filter.

const isAutoLabelWithName = (names: string[]) =>
  (label: Label) =>
    label.type === LabelType.AutoLabel
    && names.include(label.name);

Now isAutoLabelWithName can be used without needing to know anything about isGroupPage.

We can now use this with filter:

const filteredLabels = filter<Label>(
  isAutoLabelWithName(
     isGroupPage
       ? ['system_a', 'special']
       : ['system_a'],
  data.community.labels
);

But there’s a problem here. In the original code, we wanted to remove the labels that evaluated to true. This is the opposite of that.

In set theory, this is called the complement. Ramda has a complement function for this exact purpose.

const filteredLabels = filter<Label>(
  complement(
    isAutoLabelWithName(
      isGroupPage
        ? ['system_a', 'special']
        : ['system_a']
  ),
  data.community.labels
);

The second condition is simpler given it uses no free variables.

const isUnusedUserDefinedLabel = (label: Label) =>
  label.type === LabelType.UserDefined
  && label.stats.timesUsed <= 0;

Similar to isAutoLabelWithName any Label that is true for isUnusedUserDefinedLabel should be removed from the list.

Since either being true should remove the Label from the collection, Ramda’s anyPass can combine the two conditions:

const filteredLabels = filter<Label>(
  complement(
    anyPass(
      isAutoLabelWithName(
        isGroupPage
          ? ['system_a', 'special']
          : ['system_a'],
      isUnusedUserDefinedLabel
    )
  ),
  data.community.labels
);

Addressing the free variable this can be extracted into its own globally declared function that describes its purpose:

const filterLabelsForMenu = (isGroupPage: boolean) =>
  filter<Label>(
    complement(
      anyPass(
        isAutoLabelWithName(
          isGroupPage
          ? ['system_a', 'special']
          : ['system_a'],
        isUnusedUserDefinedLabel
      )
    );

The <LabelMenu> component cleans up to:

import { anyPass, ascend, complement, filter, sortWith } from 'ramda';
import { Label } from '../generated/graphql';

type Props = { isGroupPage: boolean };

const isAutoLabelWithName = (names: string[]) =>
  (label: Label) =>
    label.type === LabelType.AutoLabel
    && names.include(label.name);

const isUnusedUserDefinedLabel = (label: Label) =>
  label.type === LabelType.UserDefined
  && label.stats.timesUsed <= 0;

const filterLabelsForMenu = (isGroupPage: boolean): (labels: Label[]) => Label[] =>
  filter<Label>(
    complement(
      anyPass(
        isAutoLabelWithName(
          isGroupPage
          ? ['system_a', 'special']
          : ['system_a'],
        isUnusedUserDefinedLabel
      )
    );

export const sortLabelsByTypeAndName = sortWith<Label>(
  [
    ascend((label) => label.type === LabelType.AutoLabel ? -1 : 1),
    ascend((label) => label.name.toLowercase()),
  ]
);

const LabelMenu = ({isGroupPage, labels: Label[]}: Props): JSX.Element =>
  const filterForGroup = filterLabelsForMenu(isGroupPage);
  const filteredLabels = filterForGroup(labels);
  const sortedLabels = sortLabelsByTypeAndName(filteredLabels);


  return (
    <>{
      sortedLabels.map((label) => <>{/* React UI */}</>)
    }</>
  );
};

The example above is very close to what we ended up landing.

However, since I like to get a little too ridiculous with functional programming patterns I decided to take it a little further in my own time.

Going too far with pipe

The <LabelMenu /> component has one more step that can be converted over to Ramda using map.

Ramda’s map is similar to Array.prototype.map but using Ramda’s curried, data-as-final-argument style of API.

const labelOptions = map<Label>(
  (label) => <>{/* React UI */}</>
);

return <>{labelOptions(sortedLabels)}</>;

labelOptions is now a function that takes a list of labels (Label[]) and returns a list of React nodes (JSX.Element[]).

The <LabelList /> component now has a very interesting implementation.

  • filterLabelsForMenu returns a function of type (labels: Label[]) => Label[]
  • sortLabelByTypeAndName is a function of type (labels: Label[]) => Label[].
  • labelOptions is a function of type (labels: Label[]) => JSX.Element[].

The output of each of those functions is given as the input of the next.

Taking away all of the variable assignments this looks like:

const LabelMenu = ({isGroupPage, labels: Label[]}: Props): JSX.Element => {

  const labelOptions = map(
    (label) => <>{/* React UI */}</>,
    sortLabelsByTypeAndName(
      filterLabelsForMenu(isGroupPage)(
        labels
      )
    )
  );

  return <>{labelOptions}</>;
};

To understand how labelOptions becomes JSX.Element[] we are required to read from the innermost parentheses to the outermost.

  • filteredLabelsForMenu is applied with props.isGroupPage
  • the returned filter function is applied with props.labels
  • the returned filtered labels are applied to sortLabelsByTypeAndName
  • the returned sorted labels are applied to map(<></>)
  • the result is JSX.Element[]

We can take advantage of Ramda’s pipe to express these operations in list form.

Performs left-to-right function composition. The first argument may have any arity; the remaining arguments must be unary.

We’re in luck, all of our functions are unary. We can line them up:

const LabelMenu = ({isGroupPage, labels}: Props) =>
  const createLabelOptions = pipe(
    filterLabelsForMenu(isGroupPage),
    sortLabelsByTypeAndName,
    map(label => <key={label.id}>{label.name}</>)
  );

  return <>{createLabelOptions(labels)}</>
}

The application of pipe assigned to createLabelOptions produces a function with the type signature:

type createLabelOptions: (labels: Label[]) => JSX.Element[];

But wait, there’s more!

React’s functional components are also plain functions. Ramda can use those too!

The type signature of <LabelMenu /> is:

type LabelMenu = ({isGroupPage: boolean, labels: Label[]}) => JSX.Element;

We can update our pipe to wrap the list in a single element as its final operation:

export const LabelMenu = ({isGroupPage, labels}: Props): JSX.Element => {
  const createLabelOptions = pipe(
    filterLabelsForMenu(isGroupPage),
    sortLabelsByTypeAndName,
    map(label =>
      <li key={label.id}>
        {label.name}
      </li>
    ),
    (elements): JSX.Element =>
       <ul>{elements}</ul>
  );

  return createLabelOptions(labels);
}

The type signature of our pipe application (createLabelOptions) is now:

const createLabelOptions: (x: Label[]) => JSX.Element

Wait a second, that looks very close to a React.VFC compatible signature.

Our pipe expects input of a single argument of Label[]. But what if we changed it to accept an instance of Props?

export const LabelMenu = (props: Props): JSX.Element => {
  const createLabelOptions = pipe(
    (props: Props) =>
      filterLabelsForMenu(props.isGroupPage)(props.labels),

    sortLabelsByTypeAndName,

    map((label: Label) =>
      <li key={label.id}>{label.name}</li>
    ),
    
    (elements): JSX.Element =>
      <ul>{elements}</ul>
  );

  return createLabelOptions(props);
}

Now the type signature of createLabelOptions is:

const createLabelOptions: (x: Props) => JSX.Element

So if our application of Ramda’s pipe produces the exact signature of our a React.FunctionComponent then it stands to reason we can get rid of the function body completely:

type Props = { isGroupPage: boolean, labels: Label[] };

export const LabelMenu: React.VFC<Props> = pipe(

    (props: Props) =>
      filterLabelsForMenu(props.isGroupPage)(props.labels),

    sortLabelsByTypeAndName,

    map(label => <li key={label.id}>{label.name}</li>),

    (elements) => <ul>{elements}</ul>
  );

The ergonomics of code like this is debatable. I personally like it for my own projects. I find the more I think and write in terms of data pipelines the clearer tho code becomes.

Here’s an interesting problem. What happens if we need to use a React hook in a component like this? We’ll need a valid place to call something like React.useState() which means we’ll need to create a closure for component implementation.

This makes sense though! A functionally pure component like this is not able to have side-effects. React hooks are side-effects.

Designing at the Type Level

The <LabelMenu /> component has a type signature of

type Props = {isGroupPage: boolean, labels: Label []};
type LabelMenu = React.VFC<Props>

It renders a list of the labels it is given while also sorting and filtering them due to some business logic.

We extracted much of this business logic into pure functions that encoded our business rules into plain functions that operated on our types.

When I use <LabelMenu /> I know that I must give it isGroupPage and labels props. The labels property seems pretty self-explanatory, but the isGroupPage doesn’t really make anything obvious about what it does.

I could go into the <LabelMenu /> code and discover that isGroupPage changes which LabelType.AutoLabel labels are displayed.

But what if I wanted another <LabelMenu /> that looked exactly the same but behaved slightly differently?

I could add some more props to <LabelMenu /> that changed how it internally filtered and sorted the labels I give it, but adding more property flags to its interface feels like the wrong kind of complexity.

How about disconnecting the labels from the filtering and sorting completely?

Start by Simplifying

I’ll first simplify the <LabelMenu /> implementation:

type Props = { labels: Label[] };

const LabelMenu = (props: Props) => (
  <ul>
    {labels.map(
      (label) => <li key={label.id}>{label.name}<li>
    )}
  <ul>
);

This implementation should contain everything about how these elements should look and render every label it gets.

But what about our filtering and sorting logic?

We had a component with this type signature:

type Props = { isGroupPage: boolean, labels: Label[] };
type LabelMenu = React.VFC<Props>;

Can we express the original component’s interface without changing <LabelMenu />‘s implementation?

If we can write a function that maps from one set of props to the other, then we should also be able to write a function that maps from one React component to the other.

First write the function that uses our original Props interface as its input, and then returns the new Props interface as its return value.

type LabelMenuProps = { labels: Label[] }; 

type FilterPageLabelMenuProps = {
  isGroupPage: boolean,
  labels: Label []
};

const propertiesForFilterPage = pipe(
    (props: FilterPageLabelMenuProps) =>
      filterLabelsForMenu(props.isGroupPage)(props.labels),

    sortLabelsByTypeAndName,

    (labels) = ({ labels })
  );

There’s our Ramda implementations again. We took out all of the React bits. It’s the same business logic but without the React element rendering. The only difference is instead of mapping the labels into JSX.Elements the labels are returned in the form of LabelMenuProps.

We’ve encoded our business logic into a function that maps from FilterPageLabelMenuProps to LabelMenuProps.

That means the output of propertiesForFilterPage can be used as the input to <LabelMenu />, which is itself a function that returns a JSX.Element.

Piping one function’s output into a compatible function’s input, that sounds familiar, doesn’t it?

export const FilterPageMenuLabel: React.VFC<FilterPageLabelMenuProps> =
  pipe(
    (props: FilterPageLabelMenuProps) =>
      filterLabelsForMenu(props.isGroupPage)(props.labels),

    sortLabelsByTypeAndName,

    (labels) = ({ labels }),

    LabelMenu
  );

We’ve leveraged our existing view specific code, but changed its behavior at the Prop level.

import { FilterPageLabelMenuProps, LabelMenu } from './components/LabelMenu';

const Foo = () => {
  const { data } = useQuery(query: LabelsQuery);

  return (
    <FilteredPageLabelMenuProps
      isGroupPage={isGroupPage}
      labels={data?.labels ?? []}
    />
  );
}

const Bar = () => {
  const { data } = useQuery(query: LabelsQuery);

  return (
    <LabelMenu labels={data?.labels ?? []} />
  );
}

When hovering over the implementation of <FilteredPageLabelMenuProps> the tooltip shows exactly how it’s implemented: