Typing a redirect HOC in TypeScript with Next.js

2.4k Views Asked by At

I'm building a Next.js project with TypeScript. I'm trying to type a HOC that redirects the user based on the Redux state.

Here is what I have so far:

import { RootState } from 'client/redux/root-reducer';
import Router from 'next/router.js';
import { curry } from 'ramda';
import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';

import hoistStatics from './hoist-statics';

function redirect(predicate: (state: RootState) => boolean, path: string) {
  const isExternal = path.startsWith('http');

  const mapStateToProps = (state: RootState) => ({
    shouldRedirect: predicate(state),
  });

  const connector = connect(mapStateToProps);

  return hoistStatics(function <T>(Component: React.ComponentType<T>) {
    function Redirect({
      shouldRedirect,
      ...props
    }: T & ConnectedProps<typeof connector>): JSX.Element {
      useEffect(() => {
        if (shouldRedirect) {
          if (isExternal && window) {
            window.location.assign(path);
          } else {
            Router.push(path);
          }
        }
      }, [shouldRedirect]);

      return <Component {...props} />;
    }

    return Redirect;
  });
}

export default curry(redirect);

I feel like I'm being close, but can't quite make sense of the last error. <Component {...props} /> yells:

Type 'Pick<T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>, "dispatch" | Exclude<keyof T, "shouldRedirect">>' is not assignable to type 'IntrinsicAttributes & T & { children?: ReactNode; }'.
  Type 'Pick<T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>, "dispatch" | Exclude<keyof T, "shouldRedirect">>' is not assignable to type 'T'.
    'T' could be instantiated with an arbitrary type which could be unrelated to 'Pick<T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>, "dispatch" | Exclude<keyof T, "shouldRedirect">>'.

What is going on here? I can make the code pass by typing the inner HOC like this:

return hoistStatics(function <T>(Component: React.ComponentType<T>) {
  function Redirect(
    props: T & ConnectedProps<typeof connector>,
  ): JSX.Element {
    useEffect(() => {
      if (props.shouldRedirect) {
        if (isExternal && window) {
          window.location.assign(path);
        } else {
          Router.push(path);
        }
      }
    }, [props.shouldRedirect]);

    return <Component {...props} />;
  }

  return Redirect;
});

But that would mean that Component gets a prop called shouldRedirect which it shouldn't. It should just receive and pass on the props it usually receives.

I had to disable two warnings.

  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  return hoistStatics(function <T>(Component: React.ComponentType<T>) {

And

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    return connector(Redirect);

hoistStatics is a higher-order HOC and looks like this.

import hoistNonReactStatics from 'hoist-non-react-statics';

const hoistStatics = (
  higherOrderComponent: <T>(
    Component: React.ComponentType<T>,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  ) => (props: T & any) => JSX.Element,
) => (BaseComponent: React.ComponentType) => {
  const NewComponent = higherOrderComponent(BaseComponent);
  hoistNonReactStatics(NewComponent, BaseComponent);
  return NewComponent;
};

export default hoistStatics;

Its types are also hacked together (as you can see in the disabled warning).

How can you type these two functions?

EDIT:

After Linda's help hoistStatics works now. Redirect still causes problems though. I asked a new question for this here.

function redirect(predicate: (state: RootState) => boolean, path: string) {
  const isExternal = path.startsWith('http');

  const mapStateToProps = (state: RootState) => ({
    shouldRedirect: predicate(state),
  });

  const connector = connect(mapStateToProps);

  return hoistStatics(function <T>(
    Component: React.ComponentType<Omit<T, 'shouldRedirect'>>,
  ) {
    function Redirect({
      shouldRedirect,
      ...props
    }: T & ConnectedProps<typeof connector>): JSX.Element {
      useEffect(() => {
        if (shouldRedirect) {
          if (isExternal && window) {
            window.location.assign(path);
          } else {
            Router.push(path);
          }
        }
      }, [shouldRedirect]);

      return <Component {...props} />;
    }

    return connector(Redirect);
  });
}

The line with connector(Redirect) still is throwing errors:

Argument of type '({ shouldRedirect, ...props }: T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>) => Element' is not assignable to parameter of type 'ComponentType<Matching<{ shouldRedirect: boolean; } & DispatchProp<AnyAction>, T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>>'.
  Type '({ shouldRedirect, ...props }: T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>) => Element' is not assignable to type 'FunctionComponent<Matching<{ shouldRedirect: boolean; } & DispatchProp<AnyAction>, T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>>'.
    Types of parameters '__0' and 'props' are incompatible.
      Type 'PropsWithChildren<Matching<{ shouldRedirect: boolean; } & DispatchProp<AnyAction>, T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>>' is not assignable to type 'T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>'.
        Type 'PropsWithChildren<Matching<{ shouldRedirect: boolean; } & DispatchProp<AnyAction>, T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>>' is not assignable to type 'T'.
          'T' could be instantiated with an arbitrary type which could be unrelated to 'PropsWithChildren<Matching<{ shouldRedirect: boolean; } & DispatchProp<AnyAction>, T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>>'.
1

There are 1 best solutions below

3
On BEST ANSWER

The Error

Typescript can have give errors when piecing together props from various spread operations because of edge cases which would lead to an invalid object.

return hoistStatics(function <T>(Component: React.ComponentType<T>) {
    function Redirect({
      shouldRedirect,
      ...props
    }: T & ConnectedProps<typeof connector>): JSX.Element {
         /*...*/
      return <Component {...props} />;
    }

The component props T can be any object. The edge case that breaks this is if T includes a required property shouldRedirect.

We destructured shouldRedirect away from the props when we wrote {shouldRedirect, ...props}. Therefore know that this props variable cannot possibly contain a variable shouldRedirect. It is type Omit<T, 'shouldRedirect'>. If we are in that edge case scenario where T has a required shouldRedirect prop, then we have a problem here: <Component {...props} /> because props would not fulfill the requirement of T.

The error you got is hard to read because the type that it outputs is so long-winded, but that type represents the props object. "'T' could be instantiated with an arbitrary type" basically means that these props might fulfill T or they might not fulfill T, depending on what T is.

The Solution

This comes up a lot when writing HOCs and you can always resort to {...props as T} if it comes down to it. Often what I do is to pass the entire object like what you've done in your "I can make the code pass" code block. If we want to drop the undesired properties and keep it type-safe, we need to disallow the bad edge case. We keep T vague but state that our Component cannot require this prop by using Omit.

function <T>(Component: React.ComponentType<Omit<T, 'shouldRedirect'>>)

Typing hoistStatics

hoistStatics is a function which takes an HOC and returns an HOC. We know that an HOC can manipulate props such that the props of the composed component (Outer) are different from those of the base component (Inner). So we can use two generics here. Whatever those two types are, hoistStatics should not change them and should return an HOC with the same signature as its argument.

We define a generalized HOC signature to keep things clean

type HOC<Inner, Outer = Inner> = (
  Component: React.ComponentType<Inner>,
) => React.ComponentType<Outer>

Then we use that type to describe hoistStatics

const hoistStatics = <I, O>(
  higherOrderComponent: HOC<I, O>
): HOC<I, O> => (BaseComponent: React.ComponentType<I>) => {
  const NewComponent = higherOrderComponent(BaseComponent);
  hoistNonReactStatics(NewComponent, BaseComponent);
  return NewComponent;
};