Search code examples
typescripttypescript-generics

why mapping of array not work same ad mapping record?


is there a reason why mapping an array does not work the same way as mapping a record? An array is, in itself, an iterable object just like a Record<number, ...>, right ?

In this code, you can observe the desired behavior for ParamRecordRecord. This gives me a data table with methods that have a parameter automatically inferred based on the data context.

I also need a version in the form of a record for arrays. ParamRecordArray is therefore an exact copy of ParamRecordRecord, except that instead of having keys enumerated in enum BBB, the keys are the array indices. However, for some reason, I cannot reproduce the same behavior as the table above. All parameters in the methods components are numbers.

a mark in red🔴 where the issue occur

enum AAA { a1 = 'a1', a2 = 'a2', a3 = 'a3' }
enum BBB { b1 = 'b1', b2 = 'b2', b3 = 'b3' }

// the data structure
type TestData<T extends number = number> = {
    _: string;
    components?: T[];
    check: (c: T) => void;
}

// version record of record

type ParamRecordRecord<T> = {
    [KA in keyof T & AAA]: {
        [KB in keyof T[KA] & BBB]?: TestData<Extract<T[KA][KB], number> extends never ? number : Extract<T[KA][KB], number>>
    }
}

new class TestRecorRecord<T> {
    constructor(data: ParamRecordRecord<T>) { }
}(
    {
        [AAA.a1]: {
            [BBB.b1]: {
                _: '',
                check: (c) => { } //🟢number
                //     ^?
            },
        },
        [AAA.a2]: {
            [BBB.b2]: {
                _: '',
                components: [1],
                check: (c) => { }//🟢1
                //     ^?
            },
            [BBB.b1]: {
                _: '',
                components: [2],
                check: (c) => { }//🟢2
                //     ^?
            },
        },
    }
)



// version record of array

//This is a copy of ParamRecordRecord, except that the iteration is done over the index of the object array.
//The index keys are numbers or `${number}`, rather than strings coming from the BBB enumerator.
type ParamRecordArray<T> = {
    [KA in keyof T & AAA]: {
        [KB in keyof T[KA] & `${number}`]?: TestData<Extract<T[KA][KB], number> extends never ? number : Extract<T[KA][KB], number>>
    }
}

new class TestRecorArray<T> {
    constructor(data: ParamRecordArray<T>) { }
}(
    {
        [AAA.a1]: [
            {
                _: '',
                check: (c) => { }//🟢number
                //     ^?
            },
        ],
        [AAA.a2]: [
            {
                _: '',
                components: [1],
                check: (c) => { }//🔴1
                //     ^?
            },
            {
                _: '',
                components: [2],
                check: (c) => { }//🔴2
                //     ^?
            },
        ],
    }
)


//ok not work so let try debug ?
type debug = ParamRecordArray<{ [AAA.a1]: [ TestData<1>, TestData<2> ] }>
//   ^?
// ya it seem not able to extract the generic , but am not understand why !?

playground

Thank you in advance for your clarification and for a solution if possible.

NOTE: i dont want use as const or a function<const T>():T everywhere for each entries just for array versions. const T in the class can be a solution but seem not work !

I also could have used a single wrapper, but the const T type doesn't seem to handle nested Record<string, TestData[]> in this case.


Solution

  • You've run into a longstanding bug reported at microsoft/TypeScript#27995 where mapped types over array and tuple types don't quite behave like homomorphic mapped types (see What does "homomorphic mapped type" mean?) unless the type being mapped over is a generic type parameter. If T is a generic type parameter, then {[K in keyof T]: F<T[K]>} will map array types to array types and tuple types to tuple types. But otherwise it will map over all the keys of the array type including things like "length" and "push". Homomorphic mapped types allow for inference of T from a value of type {[K in keyof T]: F<T[K]>}, and this sort of inference is what you need for your code to work. It isn't happening, so you're getting bad inference.

    This is considered a bug but it's not clear when or if it will ever be fixed. Until and unless that happens, you can work around it by just refactoring so that the type being mapped over is a generic type parameter; that is, make a utility type:

    type ParamRecordArray<T> = {
        [KA in keyof T & AAA]: F<T[KA]>
    }
    
    type F<T> = { [KB in keyof T]?: TestData<
        Extract<T[KB], number> extends never ? number : Extract<T[KB], number>
    > }
    

    Here F<T[KA]> is doing what your original { [KB in keyof T[KA] & ${number}]?: ⋯ } was doing. Note that there's no reason to intersect with `${number}` since all that will do is break the homomorphic mapping and prevent the inference you need, and homomorphic mapped array/tuples already only iterate over the numeric-like indices.

    Let's make sure it works:

    const z = new class TestRecorArray<T> {
        constructor(data: ParamRecordArray<T>) { }
    }(
        {
            [AAA.a1]: [
                {
                    _: '',
                    check: (c) => { }//🟢number
                    //     ^? (parameter) c: number
                },
            ],
            [AAA.a2]: [
                {
                    _: '',
                    components: [1],
                    check: (c) => { }//🟢1
                    //     ^? (parameter) c: 1
                },
                {
                    _: '',
                    components: [2],
                    check: (c) => { }//🟢2
                    //     ^? (parameter) c: 2
                },
            ],
        }
    )
    

    Looks good!

    Playground link to code