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>>>'.
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.
The component props
Tcan be any object. The edge case that breaks this is ifTincludes a required propertyshouldRedirect.We destructured
shouldRedirectaway from the props when we wrote{shouldRedirect, ...props}. Therefore know that thispropsvariable cannot possibly contain a variableshouldRedirect. It is typeOmit<T, 'shouldRedirect'>. If we are in that edge case scenario whereThas a requiredshouldRedirectprop, then we have a problem here:<Component {...props} />becausepropswould not fulfill the requirement ofT.The error you got is hard to read because the type that it outputs is so long-winded, but that type represents the
propsobject. "'T' could be instantiated with an arbitrary type" basically means that these props might fulfillTor they might not fulfillT, depending on whatTis.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 keepTvague but state that ourComponentcannot require this prop by usingOmit.Typing hoistStatics
hoistStaticsis 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,hoistStaticsshould not change them and should return an HOC with the same signature as its argument.We define a generalized
HOCsignature to keep things cleanThen we use that type to describe
hoistStatics