Search code examples
reactjstypescriptgenericsrecompose

Using [key:string]: any to avoid needing an extra function for generics?


I am using recompose in React, in particular its compose function which has a signature of

function compose<TInner, TOutter>(
    ...functions: Function[]
): ComponentEnhancer<TInner, TOutter>;

interface ComponentEnhancer<TInner, TOutter> {
    (component: Component<TInner>): ComponentClass<TOutter>;
}

where I am passing in some React higher order components. I defined my TInner and TOuter types as interfaces that describe what I need in order for my HoCs to function:

interface IOuterProps {
    somethingYouGiveMe: string;
}

interface IInnerProps {
    somethingIGiveBack: number;
}

const myEnhancer = compose<IInnerProps, IOuterProps>(myHoC, myOtherHoc);

(as a silly example for demonstration). But the problem is, my components have more props than that, the HoC that compose generates needs to take those extra props and pass them through, for example:

interface IMyComponentProps {
    somethingYouGiveMe: string;
    somethingElse: string;
}

Because of this, I can't do const MyEnhancedComponent = myEnhancer(MyComponent), as the compiler will complain that MyEnhancedComponent does not have somethingElse as a prop.

I found two work arounds, neither I am happy with. So curious what more experienced TypeScript devs would do.

Work Around #1: Introduce a function

By introducing a function, I can use generics and express to the compiler what I am doing

function myEnhancer<TInnerProps extends IInnerProps, TOuterProps extends IOuterProps>(Component: React.ComponentType<TInnerProps>) {
    return compose<TInnerProps, TOuterProps>(mYHoC, myOtherHoc)(Component);
}

I really dislike this, introducing a function that will create multiple copies of the enhancer, just to get at generics? Seems wrong to me.

Work Around #2: use [key:string]: any

I can instead change my interfaces to be

interface IOutterProps {
    somethingYouGiveMe: string;
    [key: string]: any;
}

interface IInnerProps extends IOuterProps {
    somethingIGiveBack: number;
}

I am basically using [key:string]: any as a poor man's extends IOutterProps. As in, I need you to give me these props, and if you give me extras then I don't really care about those.

This allows me to use myEnhancer where ever I have components that meet the requirements, avoids the added function, and feels better than workaround #1. But also it feels wrong to have to add [key: string]: any.


Solution

  • You can make the function signature inside ComponentEnhancer generic:

    declare function compose<TInner, TOutter>(
        ...functions: Function[]
    ): ComponentEnhancer<TInner, TOutter>;
    
    interface ComponentEnhancer<TInner, TOutter> {
        // This is now a generic function 
        <TActualInner extends TInner, TActualOuterProps extends TOutter>(component: React.ComponentClass<TActualInner>): React.ComponentClass<TActualOuterProps>;
    }
    
    interface IOuterProps {
        somethingYouGiveMe: string;
    }
    
    interface IInnerProps {
        somethingIGiveBack: number;
    }
    
    const myEnhancer = compose<IInnerProps, IOuterProps>();
    interface IMyComponentProps {
        somethingYouGiveMe: string;
        somethingElse: string;
    }
    interface IMyInnerComponentProps {
        somethingElse: string; 
        somethingIGiveBack: number;
    }
    
    class MyComponent extends React.Component<IMyInnerComponentProps>{ }
    // We specify the actual inner and outer props
    const MyEnhancedComponent = myEnhancer<IMyInnerComponentProps, IMyComponentProps>(MyComponent)
    

    If we consider the logic of how the transformation works we could even use some conditional type magic to avoid specifying the type arguments explicitly. If my understanding of compose is right, what happens is that the resulting component will have all the properties of the inner component excluding the properties of IInnerProps and including the properties of IOuterProps:

    type Omit<T, TOmit> = { [P in Exclude<keyof T, keyof TOmit>] : T[P] }
    type ComponentEnhancerProps<TActualInner, TInner, TOuter> = Omit<TActualInner, TInner> & TOuter;
    interface ComponentEnhancer<TInner, TOutter> {
        <TActualInner extends TInner>(component: React.ComponentClass<TActualInner>): React.ComponentClass<ComponentEnhancerProps<TActualInner, TInner, TOutter>>;
    }
    
    
    class MyComponent extends React.Component<IMyInnerComponentProps>{ }
    const MyEnhancedComponent = myEnhancer(MyComponent)
    
    let d = <MyEnhancedComponent somethingYouGiveMe="0" somethingElse="" />
    

    The problem with this automated approach is that optional fields become required, you can keep optionality, if you have strictNullChecks enabled

    type ExcludeUndefined<T, TKeys extends keyof T> = { [P in TKeys]: undefined extends T[P]  ? never : P}[TKeys];
    type Omit<T, TOmit> = 
        { [P in ExcludeUndefined<T, Exclude<keyof T, keyof TOmit>>] : T[P] } &
        { [P in Exclude<Exclude<keyof T, keyof TOmit>, ExcludeUndefined<T, Exclude<keyof T, keyof TOmit>>>] ? : T[P] }
    type ComponentEnhancerProps<TActualInner, TInner, TOuter> = Omit<TActualInner, TInner> & TOuter;
    interface ComponentEnhancer<TInner, TOutter> {
        <TActualInner extends TInner>(component: React.ComponentClass<TActualInner>): React.ComponentClass<ComponentEnhancerProps<TActualInner, TInner, TOutter>>;
    }
    
    interface IMyInnerComponentProps {
        somethingElse: string; 
        somethingIGiveBack: number;
        optionalProp?: number;
    }
    
    
    class MyComponent extends React.Component<IMyInnerComponentProps>{ }
    const MyEnhancedComponent = myEnhancer(MyComponent)
    
    let d = <MyEnhancedComponent somethingYouGiveMe="0" somethingElse="" />