Search code examples
typescriptreduxtypescript-typingshigher-order-componentstypescript-types

Telling TypeScript that T should be allowed to be instantiated with an arbitrary type


EDIT: Here is a link to a minimal reproducible example. I had to include the external libraries Redux and React Router Dom to reproduce it faithfully.


I'm building a Next.js app with Redux and TypeScript.

I have a higher-order higher-order component that hoists statics.

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

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

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

export default hoistStatics;

Using this, I want to write a redirect HOC that can redirect users based on Redux state. Here is what I have.

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

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<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);
  });
}

export default curry(redirect);

The problem here is that return connector(Redirect); throws the following TS error.

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>>>'.ts(2345)

How can I fix this typing? I thought that telling TypeScript about the "any props" T in hoist-statics and then in Redirect's props would suffice.


Solution

  • So there's an actual mistake and then there's a bunch of bullshit.

    The actual mistake is that Redirect needs to use PropsWithChildren.

    The bullshit has to do with the Matching utility type from the react-redux package which is used for joining the injected props with the components own props.

    /**
     * A property P will be present if:
     * - it is present in DecorationTargetProps
     *
     * Its value will be dependent on the following conditions
     * - if property P is present in InjectedProps and its definition extends the definition
     *   in DecorationTargetProps, then its definition will be that of DecorationTargetProps[P]
     * - if property P is not present in InjectedProps then its definition will be that of
     *   DecorationTargetProps[P]
     * - if property P is present in InjectedProps but does not extend the
     *   DecorationTargetProps[P] definition, its definition will be that of InjectedProps[P]
     */
    export type Matching<InjectedProps, DecorationTargetProps> = {
        [P in keyof DecorationTargetProps]: P extends keyof InjectedProps
            ? InjectedProps[P] extends DecorationTargetProps[P]
                ? DecorationTargetProps[P]
                : InjectedProps[P]
            : DecorationTargetProps[P];
    };
    

    We get an error between these two types here, saying that A<T> is not assignable to B<T>.

    type A<T> = PropsWithChildren<Matching<{ shouldRedirect: boolean; } & DispatchProp<AnyAction>, T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>>
    type B<T> = PropsWithChildren<T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>
    

    Those two types are basically the same 99.9% of the time. Again we are looking at what weird edge case would make it not true.

    Basically the only time it doesn't work is if T has a prop shouldRedirect or dispatch with a type that is narrower than what we injected, ie. true instead of boolean.

    InjectedProps is { shouldRedirect: boolean; } & DispatchProp<AnyAction>

    DecorationTargetProps is T & { shouldRedirect: boolean; } & DispatchProp<AnyAction>>

    Honestly my brain is exploding thinking about which of the bullet point rules is the problem, but I can see the issue comparing lines 87 and 88 of this playground.

    So at this point... just as any and move on with your life.