1

I'm wondering if I have the correct typing for an API HOC's return type in the following scenario:

  • I have an Authentication HOC, withAuthentication, which injects auth services into a component's props.

  • I have an API HOC, withRestApi, which injects API calls, and which itself uses withAuthentication.

  • MyComponent needs to make use of withRestApi's injected functions, and has its own props.

(In the actual app, withAuthentication also needs a react-router HOC, but am trying to keep things as simple as possible in this example.)

I've based my code so far on James Ravencroft's excellent post on HOCs and Typescript, and additionally this SO post on injected HOC props, which helped to solve an issue in which HOC props were being exposed to the parent of the wrapped component.

What I'm trying to achieve is:

  • Access to this.props.getResultOfApiCall from within the MyComponent class, but hidden for any parent component.
  • Access to this.props.isAuthenticated from within the WithRestApi class, but hidden for any parent component.
  • Ability to set componentProp on MyComponent from a parent component.

Code as follows:

MyBase.tsx, enclosing component purely to demonstrate use of MyComponent's prop:

import * as React from 'react';

import MyComponent from './MyComponent';

class MyBase extends React.Component {
    public render() {
        return (
            <>
                <h1>RESULT</h1>
                <MyComponent componentProp={'Prop belonging to MyComponent'} />
            </>
        );
    }
}

export default MyBase;

MyComponent.tsx, which uses the API:

import * as React from 'react';

import { IWithRestApiProps, withRestApi } from './WithRestApi';

interface IMyComponentProps extends IWithRestApiProps {
    componentProp: string;
}

class MyComponent extends React.Component<IMyComponentProps> {
    public render() {
        return (
            <>
                <h2>Component prop: {this.props.componentProp}</h2>
                <h2>API result: {this.props.getResultOfApiCall()}</h2>
            </>
        );
    }
}

export default withRestApi(MyComponent);

WithAuthentication.tsx (putting this first because it's not the problem... as far as I can tell):

import * as React from 'react';

export interface IWithAuthenticationProps {
    isAuthenticated: () => boolean;
}

export const withAuthentication = <P extends IWithAuthenticationProps>(Component: React.ComponentType<P>):
    React.ComponentType<Pick<P, Exclude<keyof P, keyof IWithAuthenticationProps>>> =>
    class WithAuthentication extends React.Component<P> {
        public render() {

            const { isAuthenticated, ...originalProps } = this.props as IWithAuthenticationProps; 

            return (
                <Component
                    {...originalProps}
                    isAuthenticated={this.isAuthenticated}
                />
            );
        }

        private readonly isAuthenticated = (): boolean => {
            return true;
        }
    }

WithRestApi.tsx, which contains the typing problem.

import * as React from 'react';

import { IWithAuthenticationProps, withAuthentication } from './WithAuthentication';

export interface IWithRestApiProps extends IWithAuthenticationProps {
    getResultOfApiCall: () => string;
}

export const withRestApi = <P extends IWithRestApiProps>(Component: React.ComponentType<P>):
    React.ComponentType<Pick<P, Exclude<keyof P, keyof IWithRestApiProps>>> =>
    withAuthentication(class WithRestApi extends React.Component<P> {
        public render() {

            const { getResultOfApiCall, ...originalProps } = this.props as IWithRestApiProps; 

            return (
                <Component
                    {...originalProps}
                    getResultOfApiCall={this.getApiData}
                />
            );
        }

        private readonly getApiData = () => {
            if (this.props.isAuthenticated()) {
                return 'Some API result';
            } else {
                return 'Not authenticated';
            }
        }
    }) as React.ComponentType<P>; // TODO - remove this type assertion...?

This code builds, but as you can see I've had to type-assert the return value of the withApi HOC to React.ComponentType<P>. Without that assertion I see this Typescript error:

[ts]
Type 'ComponentType<Pick<P, Exclude<keyof P, "isAuthenticated">>>' is not assignable to type 'ComponentType<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>>'.
  Type 'ComponentClass<Pick<P, Exclude<keyof P, "isAuthenticated">>, ComponentState>' is not assignable to type 'ComponentType<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>>'.
    Type 'ComponentClass<Pick<P, Exclude<keyof P, "isAuthenticated">>, ComponentState>' is not assignable to type 'ComponentClass<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>, ComponentState>'.
      Type 'Pick<P, Exclude<keyof P, "isAuthenticated">>' is not assignable to type 'Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>'.
        Type 'Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">' is not assignable to type 'Exclude<keyof P, "isAuthenticated">'.

This is the first time I've run into a need for Pick..Exclude, so am a little hazy on exactly how the type is being matched in this case. I'm wondering if there's something about the way I'm using it here that could be improved to remove the need for the type assertion?

1 Answer 1

1

The first problem is that the component classes in the HOCs are defined incorrectly, as you'll see if you enable the strictFunctionTypes compiler option. The props coming from the outside to WithAuthentication do not include isAuthenticated; WithAuthentication generates that prop itself. So the props type of WithAuthentication should exclude IWithAuthenticationProps:

// ...
class WithAuthentication extends React.Component<Pick<P, Exclude<keyof P, keyof IWithAuthenticationProps>>> {
    public render() {
        const originalProps = this.props;
        // ...
    }
}
// ...

And similarly for WithRestApi (and remove the type assertion):

// ...
withAuthentication(class WithRestApi extends React.Component<Pick<P, Exclude<keyof P, keyof IWithRestApiProps>> & IWithAuthenticationProps> {
    public render() {
        const originalProps = this.props;
        // ...
    }
    // ...
}
// ...

Now you are in a world of hurt because TypeScript is unable to simplify complex combinations of Pick and Exclude types in WithRestApi.tsx. The error (pretty printed by me) is:

Type 'ComponentType<
  Pick<
    Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>
      & IWithAuthenticationProps,
    Exclude<Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">, "isAuthenticated">
  >
>'
is not assignable to type
'ComponentType<Pick<P, Exclude<keyof P, "getResultOfApiCall" | "isAuthenticated">>>'.

We can look at this error message and realize that the two types will be equivalent for any choice of P, but TypeScript doesn't have the necessary algebraic rules to prove this.

So I would suggest a different approach. For each HOC, 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. TypeScript is much better at simplifying and performing inference involving intersections. The drawback is that the props type of MyComponent has to be written as an intersection of the matching form instead of just an interface. You can define type aliases to generate the necessary intersections. The solution:

WithAuthentication.tsx

import * as React from 'react';

// The `{[withAuthenticationPropsMarker]?: undefined}` constituent ensures that
// `WithAuthenticationProps<{}>` is still an intersection so that the inference
// rule that throws out matching constituents between
// `WithAuthenticationProps<{}>` and `WithAuthenticationProps<OrigProps>` still
// works.  In case that rule isn't applicable, the checker tags each union or
// intersection type with the first type alias reference it sees that produces
// the union or intersection type, and there's an inference rule that matches up
// the type arguments of union or intersection types produced by instantiating
// the same type alias.  Normally this is fragile because it depends on the
// desired type alias being the first one seen in the compilation, but our use
// of a unique marker should ensure that nothing else can produce and tag the
// intersection type before we do.
const withAuthenticationPropsMarker = Symbol();
export type WithAuthenticationProps<OrigProps> = OrigProps &
    {[withAuthenticationPropsMarker]?: undefined} & {
    isAuthenticated: () => boolean;
};

export const withAuthentication = <P extends {}>(Component: React.ComponentType<WithAuthenticationProps<P>>):
    React.ComponentType<P> =>
    class WithAuthentication extends React.Component<P> {
        public render() {
            return (
                <Component
                    {...this.props}
                    isAuthenticated={this.isAuthenticated}
                />
            );
        }

        private readonly isAuthenticated = (): boolean => {
            return true;
        }
    }

WithRestApi.tsx

import * as React from 'react';

import { WithAuthenticationProps, withAuthentication } from './WithAuthentication';

const withRestApiPropsMarker = Symbol();
export type WithRestApiProps<OrigProps> = OrigProps &
    {[withRestApiPropsMarker]?: undefined} & {
    getResultOfApiCall: () => string;
}

export const withRestApi = <P extends {}>(Component: React.ComponentType<WithRestApiProps<P>>):
    React.ComponentType<P> =>
    withAuthentication(class WithRestApi extends React.Component<WithAuthenticationProps<P>> {
        public render() {
            // @ts-ignore : "Rest types may only be created from object types"
            // https://github.com/Microsoft/TypeScript/issues/10727
            let {isAuthenticated, ...otherPropsUntyped} = this.props;
            let otherProps: P = otherPropsUntyped;

            return (
                <Component
                    {...otherProps}
                    getResultOfApiCall={this.getApiData}
                />
            );
        }

        private readonly getApiData = () => {
            if (this.props.isAuthenticated()) {
                return 'Some API result';
            } else {
                return 'Not authenticated';
            }
        }
    });

MyComponent.tsx

import * as React from 'react';

import { WithRestApiProps, withRestApi } from './WithRestApi';

type MyComponentProps = WithRestApiProps<{
    componentProp: string;
}>;

class MyComponent extends React.Component<MyComponentProps> {
    public render() {
        return (
            <>
                <h2>Component prop: {this.props.componentProp}</h2>
                <h2>API result: {this.props.getResultOfApiCall()}</h2>
            </>
        );
    }
}

export default withRestApi(MyComponent);

Follow-up: hiding isAuthenticated from the withRestApi wrapped component

Hiding the prop in the typings is a matter of changing the definition of WithRestApiProps to:

export type WithRestApiProps<OrigProps> = OrigProps & {
    getResultOfApiCall: () => string;
}

The prop will still be passed at runtime. If you want to avoid that, you could change WithRestApi.render to:

    public render() {
        // @ts-ignore : "Rest types may only be created from object types"
        // https://github.com/Microsoft/TypeScript/issues/10727
        let {isAuthenticated, ...otherPropsUntyped} = this.props;
        let otherProps: P = otherPropsUntyped;

        return (
            <Component
                {...otherProps}
                getResultOfApiCall={this.getApiData}
            />
        );
    }
Sign up to request clarification or add additional context in comments.

4 Comments

Many thanks for the above - this seems a much cleaner approach, but on implementing the pattern I'm finding that eg. this.props.isAuthenticated, which ideally should be concealed as an implementation detail of WithRestApi, is exposed within MyComponent. Any thoughts on how this could be avoided?
Added to the answer.
Thanks for the addition - all works well, but with one odd case - if I have a version of MyComponent without its own props (eg. type MyComponentProps = WithRestApiProps<{}>), that component can't be used in MyBase's render function without a TypeScript error: Property 'getResultOfApiCall' is missing in type '{}'. The only way I can find around this is to export default withRestApi(MyComponentNoProps) as React.ComponentType;, but again am back to type assertions. Is there a better way to do this?
Good catch. Inferring from T & { getResultOfApiCall: () => string } to OrigProps & { getResultOfApiCall: () => string } normally throws out the matching intersection constituents to get OrigProps = T, but when T = {}, T & { getResultOfApiCall: () => string } simplifies to just { getResultOfApiCall: () => string } and the logic to throw out matching constituents doesn't trigger. Unfortunately, I doubt we can change the inference logic without breaking other existing code. I updated the answer to work around the problem by adding another dummy constituent to the intersection.

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.