DEV Community

A. Sharif
A. Sharif

Posted on • Updated on

Notes on TypeScript: Pick, Exclude and Higher Order Components

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how to leverage TypeScript in a specific situation. All examples are based on TypeScript 3.2.

Pick and Exclude

These notes focus on typing higher order components in React, but it's interesting to understand omit and Exclude as we will need both functions to handle the different higher order component (hoc) implementations. With Pick we can, as the name implies, pick specific keys from a provided type definition. For example we might be using an object spread and want to select specific properties and spreading the rest. Let's take a look at the following example to get a better idea:

const { name, ...rest } = props;
Enter fullscreen mode Exit fullscreen mode

We might want to do something with name inside a function but only pass on the rest props.

type ExtractName = {
  name: string
}

function removeName(props) {
  const {name, ...rest} = props;
  // do something with name...
  return rest:
}
Enter fullscreen mode Exit fullscreen mode

Let's add types to removeName function.

function removeName<Props extends ExtractName>(
  props: Props
): Pick<Props, Exclude<keyof Props, keyof ExtractName>> {
  const { name, ...rest } = props;
  // do something with name...
  return rest;
}
Enter fullscreen mode Exit fullscreen mode

There is a lot going on here, first we extended our generic Props to include the name property.
Then we extracted the name property and returned the rest properties. To tell TypeScript how our generic rest types are structured we need to remove all ExtractName properties (name in this specific case). This is what Pick<Props, Exclude<keyof Props, keyof ExtractName>> does. Let's break this further down, to be getter a better understanding. Exclude removes specific keys:

type User = {
  id: number;
  name: string;
  location: string;
  registeredAt: Date;
};

Exclude<User, "id" | "registeredAt"> // removes id and registeredAt
Enter fullscreen mode Exit fullscreen mode

We can achieve the same thing with Pick:

Pick<User, "name" | "location">
Enter fullscreen mode Exit fullscreen mode

We can rewrite our above definition:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
type Diff<T, K> = Omit<T, keyof K>;
Enter fullscreen mode Exit fullscreen mode

Now that we have a Diff function we can rewrite our removeName function:

function removeName<Props extends ExtractName>(
  props: Props
): Diff<Props, ExtractName> {
  const { name, ...rest } = props;
  // do something with name...
  return rest;
}
Enter fullscreen mode Exit fullscreen mode

We should have a basic understanding of how Pick and Exclude function and also added Omit and Diff that we will use when will type hocs in the following section.

Higher Order Components

We will consult the official React docs for better understanding some conventions and then type the different hoc variants.
There is an important convention that we need to consider: Pass Unrelated Props Through to the Wrapped Component (see docs).

Our first example is based on an example from the docs, where we want to log props by providing a component that logs a wrapped component.

function withLogProps(WrappedComponent) {
  return class LogProps extends React.Component {
    componentWillReceiveProps(nextProps) {
      console.log("Currently available props: ", this.props);
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

We can leverage React.ComponentType which is a React specific type that will allow us to pass in a component class or function as a wrapped component. As we're not extending or narrowing any props in our withLogProps higher order component, we can pass the generic props through.

function withLogProps<Props>(WrappedComponent: React.ComponentType<Props>) {
  return class LogProps extends React.Component<Props> {
    componentWillReceiveProps() {
      console.log("Currently available props: ", this.props);
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Next, let's see how we can type a higher order component that expects additional props to show a message when an error has occurred.

function withErrorMessage(WrappedComponent) {
  return function() {
    const { error, ...rest } = props;
    return (
      <React.Fragment>
        <WrappedComponent {...rest} />
        {error && <div>{error}</div>}
      </React.Fragment>
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

The withErrorMessage looks similar to the initial example we built.


function withErrorMessage<Props>(WrappedComponent: React.ComponentType<Props>) {
  return function(props: Props & ErrorLogProps) {
    const { error, ...rest } = props as ErrorLogProps;
    return (
      <React.Fragment>
        <WrappedComponent {...rest as Props} />
        {error && <div>{error}</div>}
      </React.Fragment>
    );
  };
}
Enter fullscreen mode Exit fullscreen mode

There are some interesting aspects here, that we need to clarify.
Our hoc expands the expected props by expecting an error aside from all the props expected from the wrapped component, this can be achieved by combining the generic wrapped component props with the required error message prop: Props & ErrorLogProps.
The other interesting aspect is that we need to explicitly define which props are ErrorLogProps by typecasting our destructured props:const { error, ...rest } = props as ErrorLogProps
TypeScript will still complain when passing through the rest props, so we need to typecast the rest props as well: <WrappedComponent {...rest as Props} />. This might change in the future, but of 3.2, this is needed to prevent TypeScript from complaining.

There are situations where we want to provide specific functionalities and values to a wrapped component as well as preventing these functions and values being overridden by provided props.
Our next higher order component should narrow down the API.

Let's assume we have an Input component the expects

const Input = ({ value, onChange, className }) => (
  <input className={className} value={value} onChange={onChange} />
);
Enter fullscreen mode Exit fullscreen mode

The higher order component should provide the value and onChange properties.

function withOnChange(WrappedComponent) {
  return class OnChange extends React.Component {
    state = {
      value: "";
    };
    onChange = e => {
      const target = e.target;
      const value = target.checked ? target.checked : target.value;
      this.setState({ value });
    };
    render() {
      return (
        <WrappedComponent
          {...this.props}
          onChange={this.onChange}
          value={this.state.value}
        />
      );
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Let's define the needed prop types first.

type InputProps = {
  name: string,
  type: string
};

type WithOnChangeProps = {
  onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
  value: string | boolean
};
Enter fullscreen mode Exit fullscreen mode

This means we can define our Input component by combining these prop type definitions.

const Input = ({
  value,
  onChange,
  type,
  name
}: InputProps & WithOnChangeProps) => (
  <input type={type} name={name} value={value} onChange={onChange} />
);
Enter fullscreen mode Exit fullscreen mode

Adding types to the withOnChange component, we can apply everything we have learned so far.

type WithOnChangeState = {
  value: string | boolean;
};

function withOnChange<Props>(WrappedComponent: React.ComponentType<Props>) {
  return class OnChange extends React.Component<
    Diff<Props, WithOnChangeProps>,
    WithOnChangeState
  > {
    state = {
      value: ""
    };
    onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
      const target = event.target;
      const value = target.type === "checkbox" ? target.checked : target.value;
      this.setState({ value });
    };
    render() {
      return (
        <WrappedComponent
          {...this.props as Props} // we need to be explicit here
          onChange={this.onChange}
          value={this.state.value}
        />
      );
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

By using our previously defined Diff type we can extract all keys that we want to prevent from being overridden. This enables us to provide our Input component with the onChange and value properties.

const EnhancedInput = withOnChange(Input);

// JSX
<EnhancedInput type="text" name="name" />;
Enter fullscreen mode Exit fullscreen mode

There are situations where we need to expand the props, f.e. we would like to enable developers using withOnChange to provide an initial value. We can rewrite our component by enabling to provide an initialValue property.

type ExpandedOnChangeProps = {
  initialValue: string | boolean;
};

function withOnChange<Props>(WrappedComponent: React.ComponentType<Props>) {
  return class OnChange extends React.Component<
    Diff<Props, WithOnChangeProps> & ExpandedOnChangeProps,
    WithOnChangeState
  > {
    state = {
      value: this.props.initialValue
    };
    onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
      const target = event.target;
      const value = target.type === "checkbox" ? target.checked : target.value;
      this.setState({ value });
    };
    render() {
      const { initialValue, ...props } = this.props as ExpandedOnChangeProps;
      return (
        <WrappedComponent
          {...props as Props} // we need to be explicit here
          onChange={this.onChange}
          value={this.state.value}
        />
      );
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

There are two interesting things to note here. We expanded the OnChange class props by defining Diff<Props, WithOnChangeProps> & ExpandedOnChangeProps, the other important is that we have to remove initialValue from the props passed down to our wrapped component. We have seen this done in our initial example, by spreading the generic props and removing the initialValue:

const { initialValue, ...props } = this.props as ExpandedOnChangeProps;

Another possible case, where we might want to provide a higher order component is when we want to define a generic component, that expects a wrapped component as well as additional configurations or functionalities. Let's write a component that expects a fetch function and a component and returns a component that depending on the result of the fetch either displays nothing, a loading indicator, an error message or in case of a successful fetch the wrapped component.

function withFetch(fetchFn, WrappedComponent) {
  return class Fetch extends React.Component {
    state = {
      data: { type: "NotLoaded" }
    };
    componentDidMount() {
      this.setState({ data: { type: "Loading" } });
      fetchFn()
        .then(data =>
          this.setState({
            data: { type: "Success", data }
          })
        )
        .catch(error =>
          this.setState({
            data: { type: "Error", error }
          })
        );
    }
    render() {
      const { data } = this.state;
      switch (data.type) {
        case "NotLoaded":
          return <div />;
        case "Loading":
          return <div>Loading...</div>;
        case "Error":
          return <div>{data.error}</div>;
        case "Success":
          return <WrappedComponent {...this.props} data={data.data} />;
      }
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

There's some work we need to do to prevent TypeScript from complaining.
The first thing we can do is define the actual component state:

type RemoteData<Error, Data> =
  | { type: "NotLoaded" }
  | { type: "Loading" }
  | { type: "Error", error: Error }
  | { type: "Success", data: Data };

type FetchState<Error, Data> = {
  data: RemoteData<Error, Data>
};
Enter fullscreen mode Exit fullscreen mode

We can define the promise result type that our withFetch component should expect when calling the provided function, that way we can guarantee that the returned promise result type matches the expected data property in our wrapped component.

function withFetch<FetchResultType, Props extends { data: FetchResultType }>(
  fetchFn: () => Promise<FetchResultType>,
  WrappedComponent: React.ComponentType<Props>
) {
  return class Fetch extends React.Component<
    Omit<Props, "data">,
    FetchState<string, FetchResultType>
  > {
    state: FetchState<string, FetchResultType> = {
      data: { type: "NotLoaded" }
    };
    componentDidMount() {
      this.setState({ data: { type: "Loading" } });
      fetchFn()
        .then(data =>
          this.setState({
            data: { type: "Success", data }
          })
        )
        .catch(error =>
          this.setState({
            data: { type: "Error", error }
          })
        );
    }
    render() {
      const { data } = this.state;
      switch (data.type) {
        case "NotLoaded":
          return <div />;
        case "Loading":
          return <div>Loading...</div>;
        case "Error":
          return <div>{data.error}</div>;
        case "Success":
          return <WrappedComponent {...this.props as Props} data={data.data} />;
      }
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

There are more examples we can write, but as introductory into the topic, these examples should be a building block to study the topic further.

If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Oldest comments (3)

Collapse
 
pookdeveloper profile image
pookdeveloper

Thanks

Collapse
 
dipto0321 profile image
Dipto Karmakar

Thanks, Man for sharing this.

Collapse
 
vasyan profile image
vasyan • Edited

Thank you for helpful post.
Btw this example, in the beginning, may confuse

Exclude<User, "id" | "registeredAt">

Cause User not casted to keysof