1

Intro

I am trying to create a Higher Order Component for a Dialog component in React Native. Sadly, I have some compile errors that I do not understand at all. I have followed this tutorial on Higher Order Components in TypeScript, but it does not show an example for getting ref to work.

Setup

I have a component called DialogLoading, and I export it through a Higher Order Component called withActions. The withActionscomponent defines two interfaces, to determine what props it injects and what additional props it takes in. In the following code, the type parameters C, A and P stand for ComponentType, ActionType and PropType respectively.

The interfaces are:

interface InjectedProps<A> 
{   onActionClicked: (action: A) => void;}

and

interface ExternalProps<C, A>
{   onActionClickListener?: (component: C | null, action: A) => void;}

I also declare a type alias that denotes the final props type for the HOC. This type should have all the props of the wrapped component, all the props of the ExternalProps<C, A> interface, but not the props of the InjectedProps<A> interface. This is declared as follows:

type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
type Subtract<T, K> = Omit<T, keyof K>;

type HocProps<C, A, P extends InjectedProps<A>> = Subtract<P, InjectedProps<A>> & ExternalProps<C, A>;

The Higher Order Component is then declared as follows:

export default <C, A, P extends InjectedProps<A>> (WrappedComponent: React.ComponentType<P>) =>
{
    const hoc = class WithActions extends React.Component<HocProps<C, A, P>>
    {
        ...Contents of class removed for breivity.

        private onActionClicked = (action: A) =>
        {
            this.onActionClickedListeners.forEach(listener => 
            {   listener(this.wrapped, action);});
        }

        private wrapped: C | null;

        render()
        {
            return (
                <WrappedComponent ref={i => this.wrapped = i} onActionClicked={this.onActionClicked} {...this.props} />
            );
        }
    }

    return hoc;
}

and can be used by:

<DialogLoading onActionClickListener={this.onActionClickListener} title="Loading Data" section="Connecting" />;

The Problem

On the ref callback inside the render function of the HOC, TypeScript gives me the following error messages:

[ts] Property 'ref' does not exist on type 'IntrinsicAttributes & InjectedProps<A> & { children?: ReactNode; }'.
[ts] Type 'Component<P, ComponentState, never> | null' is not assignable to type 'C | null'.
     Type 'Component<P, ComponentState, never>' is not assignable to type 'C'.

I suspect this is because the WrappedComponent that is passed in, is of type React.ComponentType<P>, which is a union type of React.ComponentClass<P> and React.SFC<P>. The error is thrown, because the stateless components in React do not accept a ref callback. A possible solution would then be to change it's type to be just React.ComponentClass<P>.

This kind of fixes the problem, but strangely enough, a new error is now thrown on the onActionClicked prop of the wrapped component! The error is:

[ts] Type '(action: A) => void' is not assignable to type '(IntrinsicAttributes & IntrinsicClassAttributes<Component<P, ComponentState, never>> & Readonly<{ children?: ReactNode; }> & Readonly<P>)["onActionClicked"]'.
WithActions.tsx(7, 5): The expected type comes from property 'onActionClicked' which is declared here on type 'IntrinsicAttributes & IntrinsicClassAttributes<Component<P, ComponentState, never>> & Readonly<{ children?: ReactNode; }> & Readonly<P>'

This second error baffles me completely. What makes it even stranger is that when I adjust the type alias for the HocProps to the following (i.e. I no longer Subtract InjectedProps<A> from P):

type HocProps<C, A, P extends InjectedProps<A>> = P & ExternalProps<C, A>;

The error on onActionClicked is removed! This seems strange to me, because the type definition of HocProps has nothing to do with the prop type of the wrapped component! This "solution", however, is undesirable to me, because now the InjectedProps<A> are injectable by the user of the HOC as well.

The Question

So where am I going wrong here?

  • Am I correct in assuming the ref callback did not work because the Wrapped Component type was React.ComponentType<P> instead of React.ComponentClass<P>?

  • Why does changing the Wrapped Component type to React.ComponentClass<P> result in a compile error on the onActionClicked prop of the Wrapped Component?

  • Why does changing the type alias for HocProps remove said error on the onActionClicked prop? Aren't they completely unrelated?

  • Is the Subtract functionality I've cooked up correct? Is that where the errors are coming from?

Any help would be greatly appreciated, so thanks in advance!

2
  • This bug is part of the problem. I will write more if I have a chance. Commented Sep 27, 2018 at 4:20
  • Thanks for the effort, Matt McCutchen! I'd love to hear from you soon. Commented Sep 27, 2018 at 9:53

1 Answer 1

2

Am I correct in assuming the ref callback did not work because the Wrapped Component type was React.ComponentType<P> instead of React.ComponentClass<P>?

Roughly speaking, yes. When you make a JSX element from the WrappedComponent, which has a union type (React.ComponentType<P> = React.ComponentClass<P> | React.StatelessComponent<P>), TypeScript finds the props type corresponding to each alternative of the union and then takes the union of the props types using subtype reduction. From checker.ts (which is too big to link to the lines on GitHub):

    function resolveCustomJsxElementAttributesType(openingLikeElement: JsxOpeningLikeElement,
        shouldIncludeAllStatelessAttributesType: boolean,
        elementType: Type,
        elementClassType?: Type): Type {

        if (elementType.flags & TypeFlags.Union) {
            const types = (elementType as UnionType).types;
            return getUnionType(types.map(type => {
                return resolveCustomJsxElementAttributesType(openingLikeElement, shouldIncludeAllStatelessAttributesType, type, elementClassType);
            }), UnionReduction.Subtype);
        }

I'm not sure why this is the rule; an intersection would make more sense for ensuring that all required props are present regardless of which alternative of the union the component is. In our example, the props type for React.ComponentClass<P> includes ref while the props type for React.StatelessComponent<P> does not. Normally, a property is considered "known" for a union type if it is present in at least one constituent of the union. However, in the example, the subtype reduction throws out the props type for React.ComponentClass<P> since it happens to be a subtype of (have more properties than) the props type for React.StatelessComponent<P>, so we're left with just React.StatelessComponent<P>, which does not have a ref property. Again, this all seems weird, but it produced an error that pointed to an actual bug in your code, so I'm not inclined to file a bug against TypeScript.

Why does changing the Wrapped Component type to React.ComponentClass<P> result in a compile error on the onActionClicked prop of the Wrapped Component?

The root cause of this error is that TypeScript is unable to reason that the combination onActionClicked={this.onActionClicked} {...this.props} of type Readonly<HocProps<C, A, P>> & { onActionClicked: (action: A) => void; } provides the required props type P. Your intent is that if you subtract onActionClicked from P and then add it back, you should be left with P, but TypeScript has no built-in rule to verify this. (There is a potential issue that P could declare an onActionClicked property whose type is a subtype of (action: A) => void, but your use pattern is common enough that I expect that if TypeScript were to add such a rule, the rule would hack around this issue somehow.)

It's confusing that TypeScript 3.0.3 reports the error on onActionClicked (though this may be due to the issue I mentioned). I tested and at some point between 3.0.3 and 3.2.0-dev.20180926, the behavior changed to report the error on WrappedComponent, which seems more reasonable, so no further follow-up is needed here.

The reason the error doesn't occur when the WrappedComponent type is React.ComponentType<P> is because for a stateless function component (unlike a component class), TypeScript only checks that you pass enough props to satisfy the constraint of the props type P, i.e., InjectedProps<A>, not actually P. I believe this is a bug and have reported it.

Why does changing the type alias for HocProps remove said error on the onActionClicked prop? Aren't they completely unrelated?

Because then {...this.props} by itself satisfies the required P.

Is the Subtract functionality I've cooked up correct? Is that where the errors are coming from?

Your Subtract is correct, but as described above, TypeScript has very little support for reasoning about the underlying Pick and Exclude.

To solve your original problem, I recommend using type aliases and intersections instead of subtractions as described in this answer. In your case, this would look like:

import * as React from "react";

interface InjectedProps<A> 
{   onActionClicked: (action: A) => void;}

interface ExternalProps<C, A>
{   onActionClickListener?: (component: C | null, action: A) => void;}

// See https://stackoverflow.com/a/52528669 for full explanation.
const hocInnerPropsMarker = Symbol();
type HocInnerProps<P, A> = P & {[hocInnerPropsMarker]?: undefined} & InjectedProps<A>;

type HocProps<C, A, P> = P & ExternalProps<C, A>;

const hoc = <C extends React.Component<HocInnerProps<P, A>>, A, P>
    (WrappedComponent: {new(props: HocInnerProps<P, A>, context?: any): C}) =>
{
    const hoc = class WithActions extends React.Component<HocProps<C, A, P>>
    {
        onActionClickedListeners;  // dummy declaration

        private onActionClicked = (action: A) =>
        {
            this.onActionClickedListeners.forEach(listener => 
            {   listener(this.wrapped, action);});
        }

        private wrapped: C | null;

        render()
        {
            // Workaround for https://github.com/Microsoft/TypeScript/issues/27484
            let passthroughProps: Readonly<P> = this.props;
            let innerProps: Readonly<HocInnerProps<P, A>> = Object.assign(
                {} as {[hocInnerPropsMarker]?: undefined},
                passthroughProps, {onActionClicked: this.onActionClicked});
            return (
                <WrappedComponent ref={i => this.wrapped = i} {...innerProps} />
            );
        }
    }

    return hoc;
}

interface DiagLoadingOwnProps {
    title: string;
    section: string;
}

// Comment out the `{[hocInnerPropsMarker]?: undefined} &` in `HocInnerProps`
// and uncomment the following two lines to see the inference mysteriously fail.

//type Oops1<T> = DiagLoadingOwnProps & InjectedProps<string>;
//type Oops2 = Oops1<number>;

class DiagLoadingOrig extends React.Component<
    // I believe that the `A` type parameter is foiling the inference rule that
    // throws out matching constituents from unions or intersections, so we are
    // left to rely on the rule that matches up unions or intersections that are
    // tagged as references to the same type alias.
    HocInnerProps<DiagLoadingOwnProps, string>,
    {}> {}
const DialogLoading = hoc(DiagLoadingOrig);

class OtherComponent extends React.Component<{}, {}> {
    onActionClickListener;
    render() {
        return <DialogLoading onActionClickListener={this.onActionClickListener} title="Loading Data" section="Connecting" />;
    }
}

(Here I've also changed the type of WrappedComponent so that its instance type is C so that the assignment to this.wrapped type-checks.)

Sign up to request clarification or add additional context in comments.

7 Comments

Hi Matt, thanks a lot for the thorough explanation. The line instead of declaring a type variable for the inner props type and defining the outer props type by excluding things, declare a type variable for the outer props type and define the inner props type as an intersection in your other answer has removed the error from my code. Sadly, now the onActionClicked prop is usable by the end-user of the HOC. Have I misunderstood something about what you meant? If we return a ComponentType<P>, P will always include the InjectedProps, right? Whether we P extends InjectedProps or &.
I don't know exactly what you tried, but I went ahead and added my version of the solution. Let me know if you have further issues or questions.
Hi Matt, thanks again for all the effort. In your example, the end-user is still able to do the following: <DialogLoading onActionClicked={(_: any) => {}} />;. I.e. the user of the HOC can still specify onActionClicked on the final HOC component. I remain uncertain as to how to prohibit this.
There were several mistakes in my code; I thought I tested that it type-checked but apparently not. Sorry. Please try the updated version.
This seems to work perfectly, thanks Matt! It's the middle of the night here, and I wont have time to properly test this out until wednesday. If everything works out by then, I'll mark your answer as accepted. Thanks again for all your diligent work! As a side node, I don't quite understand the comment on the "fragile" TypeScript rule you mention in your solution. If you could enlighten slightly on that, it would be much appreciated!
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.