Search code examples
javascripttypescriptstyled-components

Using spread syntax while wrapping functions recursively in typescript


I was trying to convert my styled-component mixin module to typescript but encountered a problem that I can't solve (in the chain method). Live playground here.

// Sample mixin methods
const fullscreen = () => `
    width: 100vw;
    height: 100vh;
`
const squared = (size: string) => `
    width: ${size};
    height: ${size};
`

interface mixinInterface {
    fullscreen: typeof fullscreen
    squared: typeof squared
}

type chainedMixinInterface = {
    [P in keyof mixinInterface]: (...args: Parameters<mixinInterface[P]>) => () => string
}

const mixins: mixinInterface & { chain: () => chainedMixinInterface } = {
    fullscreen,
    squared,
    // ...other mixin methods
    chain: function () {
        const chainedObject: Partial<chainedMixinInterface> = {}
        let accumulatedReturn = ''
        // get mixin keys without 'chain'
        const keys = Object.keys(mixins).filter(key => key !== 'chain') as (keyof mixinInterface)[] 
        keys.forEach(key => {
            const mixinFunction = mixins[key]
            // wraps subsequent mixin methods
            chainedObject[key] = function (...args: Parameters<typeof mixinFunction>) {
                accumulatedReturn += mixinFunction(...args) // Error: A spread argument must either have a tuple type or be passed to a rest parameter.
                const returnAllChainedValues = () => accumulatedReturn
                return Object.assign(returnAllChainedValues, this) // pass along the methods to be chained
            }
        })
        return chainedObject as chainedMixinInterface
    }
}

This is the use case for chain:

const Div = styled.div`
    // returns a function that returns the chained results
    // which will be called by styled-component's tag function
    ${mixins
        .chain()
        .fullscreen()
        .squared('50px')
        // ...other mixin methods
    }
`

By calling the chain function, it wraps subsequent calls to mixin methods. Instead of returning a string, it returns a function that returns the accumulated result (string) from all of the chained methods.

Problem:

Even though I specified ...args as the parameters of the mixinFunction that is currenting being iterated on, it isn't a tuple, but a union of tuples. Therefore, TypeScript throws an error in the following line, where I spread the args in the mixinFunction call. I haven't found a way to solve this problem. Is there a way to sidestep this issue by only modifying the typing, without changing the underlying JavaScript?


Solution

  • TypeScript does not do type narrowing on a case basis, it only narrows to general types. Consider the following code

    interface MyType {
        n: number,
        s: string
    }
    
    function selfAssign(a: MyType, key: keyof MyType) {
        a[key] = a[key]; // Error: Type 'string | number' is not assignable to type 'never'
    }
    
    selfAssign({ n: 3, s: 'abc'}, 'n');
    

    Even though a[key] = a[key] is clearly typesafe, TS doesn't have a type that represents "type of a[key] whatever it is".

    For all TS knows, you're trying to assign a value that might either be a number or a string, to a property that either only accepts numbers or only accepts strings.

    It says the lefthand a[key] is of type never because no value can satisfy both number and string. In a slightly different case the error would be different but the princip is the same.

    In your case, mixinFunction can be one of a few functions with a different signature each, so the type of ...args: Parameters<typeof mixinFunction> is the intersection of all those possibilities rather than a particular tuple, and therefore it's not possible to call mixinFunction with ...args as the parameters.

    If you don't want to change the underlying JS code in a way that'll allow TS to recognize the type match, you can relax the type safety by performing a cast. One such option that seems to work is

    accumulatedReturn += (mixinFunction as unknown as (...args: any) => () => string)(...args)
    

    The above code casts mixinFunction to (...args: any) => () => string which allows calling it with any parameters, but it's still relatively safe because it only affects that specific line where you immediately call the function with the actual ...args.

    The intermediate cast to unknown is apparently also needed because otherwise TS complains about the cast not being reasnoable.