typescriptmapped-types

Another TS 5.4 regression (i think).... how to go about fixing this type in 5.4


Recent Typescript changes (5.4.x, everything worked in 5.3.3) have made some regressions which caused a number of issues. The first one I posted about HERE.

The fix proposed in that other post does not work here, or at least I couldn't implement.

This post eventually resulted in a fix after the community (thanks JCalz) made an issue on the TS repo itself. At the time, to me, this seemed to be related/same so I have not posted about it, instead waiting for a fix. Having tested with the newest version (the one that had a fix) and it is till broken, thus I am posting this as a separate issue.

I am not well versed enough in the magic of typescript to be confident that this is in fact an issue on their end, versus just some mess in my code, but I am pretty certain that is the case, seeing as this code worked like a charm, and think this is another regression.

Link to the TS playground.

When attempting to use keys of a TupleIntersection inside a mapped object that would "extract" for the lack of the better word, a part of that key, the results can no longer be used to index the object that has those same keys

I would love go give a shorter example, that is not as convoluted as this, but I am not sure what else to truncate. The ExtractSelectorPropertyName must remain, as it does the key string converstion.

type TupleToIntersection<T extends any[]> = {
    [I in keyof T]: (x: T[I]) => void
}[number] extends (x: infer U) => void
    ? U
    : never

type ExtractSelectorPropertyName<T> = T extends `select${infer Name}`
    ? Uncapitalize<Name>
    : never

type SelectorKeysCollection<T extends any[]> = keyof TupleToIntersection<{
    [I in keyof T]: T[I][3]
}>

type SelectorValuesCollection<T extends any[]> = {
    [K in SelectorKeysCollection<T> as ExtractSelectorPropertyName<K>]: TupleToIntersection<{ [I in keyof T]: T[I][3] }>[K]
}

function createEnhancer<T extends any[]>() {
    function mapSelectorToValueNames<K extends SelectorKeysCollection<T>>(name: K): ExtractSelectorPropertyName<K> {
        return 'lets forget the implementation detail' as ExtractSelectorPropertyName<K>
    }

    const select = <Keys extends Array<SelectorKeysCollection<T>>>(...names: Keys) => {
        names.reduce<Partial<SelectorValuesCollection<T>>>((acc, name) => {

            // ISSUE HERE:
            acc[mapSelectorToValueNames(name)] = 'whatever' as any
            return acc
        }, {})
    }
}

This results in an error:

Type 'ExtractSelectorPropertyName<keyof TupleToIntersection<{ [I in keyof T]: T[I][3]; }>>' cannot be used to index type 'Partial<SelectorValuesCollection>'.(2536)

EDIT: Remedied the code to have a bit better example where only types are included and implementation detail is removed.

POST-EDIT: It is my understanding that TSC does not see these two types as equal, though it used to do just fine before, and what is more, the example (also included in the TSplayground link) showcases that they are indeed same:

// =====================================
// by inspecting the keys, mappedKeys and values
// we can see that the ExtractSelectorPropertyName<K>
// indeed fits the `SelectorValuesCollection<T>` indexes
// =====================================

declare function TEST<T extends any[]>():
    [
        SelectorKeysCollection<T>,
        { [K in SelectorKeysCollection<T>]: ExtractSelectorPropertyName<K> },
        SelectorValuesCollection<T>
    ]

const [keys, mappedKeys, values] = TEST<[
    ['example', null, null, {
        selectFoo: string,
        selectBar: number,
    }]
]>()

Solution

  • It's very difficult to say exactly why your code worked before, stopped working, and then apparently works again in the nightly version of TypeScript. I mean, it's relatively straightforward to give a rundown of the "what" but not the "how":

    These issues don't have anything obvious to do with each other. Your code does involve mapped types and template literal types, so it's quite possible that the break and the fix are touching different pieces of your code and it's essentially a coincidence.

    If you need to know what's going on you might try to come up with a more minimal example, and/or file your own GitHub issue about it. Until and unless that happens I don't have any further insight.


    On the other hand, a general approach for situations in which you are sure that a type X is assignable to a type Y but the compiler can't see it (generally because one or both types depend on generic types, and the compiler doesn't have much ability to do higher-order generic reasoning), you can often use the [intersection(https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types) X & Y instead of X. If you're correct about the assignability, then ultimately X & Y will be equivalent (more or less) to X. And the compiler will always recognize X & Y is assignable to X, by definition of the intersection.

    So, in your example, you could always do something like

    declare function createEnahncer<T extends any[]>(composition: T) {
      const mapSelectorToValueNames = <K extends SelectorKeysCollection<T>>(
          name: K
      ): (keyof SelectorValuesCollection<T>) & ExtractSelectorPropertyName<K>;
    

    to avoid the error. If you're using complicated types (especially things like UnionToIntersection) then you will probably eventually need something like this, even if the particular issue in your question turns out to be a real bug.

    Playground link to code