Skip to content

homomorphic mapped tuple doesn't preserve parallel tuple #46741

Closed

Description

Bug Report

🔎 Search Terms

homomorphic mapped tuples, variadic tuple, parallel lists, zip tuples together, pipeline, compose function, keyof array.

🕗 Version & Regression Information

  • This is the behaviour in every version of the playground from 4.0.5 up to nightly v4.6.0-dev2021-11-05
    • This is not applicable before 4.0.5 since tuple spread [A,...V] doesn't exist that far back
    • and I reviewed the FAQ for entries about mapped types, I don't have primitives here.

⏯ Playground Link and Explanation

Playground link with relevant code

💻 Code

interface A{a:any}
interface B{b:any}
interface C{c:any}

type Combine<X,Y> = {[K in keyof X]: [X[K], Y[K&keyof Y]]}
type XX = [A,B,C]
type YY = [1,2,3]
declare const example_value: [["x", "y"], [A, 1], [B, 2], [C, 3]]

let works_as_expected: Combine<['x',...XX], ['y',...YY]> = example_value
// let works_as_expected: [["x", "y"], [A, 1], [B, 2], [C, 3]]

type Alias<X extends any[],Y extends any[]> = Combine<['x',...X],['y',...Y]>
let doesnt_work: Alias<XX,YY> = example_value
// let doesnt_work: [["x", "y"], [A, "y"], [B, 1], [C, 2]]

🙁 Actual behavior

works_as_expected is correctly the type [['x','y'], [A, 1], [B, 2], [C, 3]] but doesnt_work which is basically defined the exact same way except the spreading is done inside another type alias gives a totally different and incorrect type [["x", "y"], [A, "y"], [B, 1], [C, 2]]

🙂 Expected behavior

example should compile without errors, the type of doesnt_work should be the same as works_as_expected.

Activity

  1. changed the title [-]mapped tuples together [/-] [+]homomorphic mapped tuple doesn't preserve parallel tuple[/+] on Nov 9, 2021
  2. tadhgmister commented on Nov 9, 2021

    @tadhgmister
    Author

    I came across this while trying to do variadic tuples with a pipeline type function, playground

    type Fn<P,R> = (arg:P)=>R
    interface A{a:any} // interfaces for testing
    interface B{b:any}
    interface C{c:any}
    interface D{d:any}
    
    // Fns<[A,B,C], [1,2,3]> == [Fn<A,1>, Fn<B,2>, Fn<C,3>]
    // these take a list of arguments and a list of return types and basically zips them together
    type Fns_RetDriven<Args,Rets> = {[K in keyof Rets]: Fn<Args[K&keyof Args], Rets[K]>}
    type Fns_ArgDriven<Args,Rets> = {[K in keyof Args]: Fn<Args[K], Rets[K&keyof Rets]>}
    
    type Fns<Args,Rets> = Fns_RetDriven<Args,Rets>
                          & Fns_ArgDriven<Args,Rets>
    // the real use case is a compose/pipeline function which seems to be infering generics properly but the type is just breaking
    declare function pipeline<Init,V extends any[],Ret>(...fns: Fns<[Init,...V],[...V,Ret]>): Fn<Init,Ret>
    
    declare const clearly_the_expected_type: Fns<[A,B,C], [B,C,D]>
    pipeline(...clearly_the_expected_type)
    //Err Argument of type '[Fn<A, B>, Fn<B, C>, Fn<C, D>]' is not 
    // assignable to parameter of type 'Fns<[A, B, C], [B, C, D]>'.

    That pipeline function is correctly inferring generics in most cases I throw at it, if it wasn't for the correctly inferred generics producing a different and invalid type this would work great. (you can tell it is clearly invalid because the error says it isn't assignable to the type it was literally declared with) I think if this issue is fixed we may finally have the variadic compose and pipeline signature that can infer some arguments of the in-between functions.

  3. ahejlsberg commented on Jan 12, 2022

    @ahejlsberg
    Member

    This one is interesting.

    When instantiating a mapped type that is applied to a tuple type with variadic elements, we transform M<[A, ...T, B]> into [...M<[A]>, ...M<T>, ...M<[B]>], and then rely on tuple type normalization to resolve the non-generic parts of the resulting tuple. So, say that M is a mapped type that "boxes" every element:

    type M<T> = { [K in keyof T]: { value: T[K] } };

    The instantiation M<[number, ...T, string]> then turns into [{ value: number }, M<T>, { value: string }]. This is generally a desirable transformation because it ensures that a mapped type applied to a variadic tuple type is itself recognized as a tuple type, plus it reduces the genericity of the type and allows better checking of and inference from the non-generic parts. However, the transformation means that the iteration type variable (K in example above) can really only be trusted to represent a property name in the type being iterated over (T in example above), and cannot be trusted to always correlate to an index in some other tuple type or a final combined tuple type.

    Here's a simpler example:

    type Foo<T> = { [K in keyof T]: K };
    
    type Bar<A extends any[]> = Foo<[0, ...A]>;  // [0, ...Foo<A>] because of transformation
    
    type T0 = Foo<[0, 0, 0, 0]>;  // ["0", "1", "2", "3"]
    type T1 = Foo<[0, ...[0, 0, 0]]>;  // ["0", "1", "2", "3"]
    type T2 = Bar<[0, 0, 0]>;  //["0", "0", "1", "2"]

    Above, in the T1 example, the type [0, ...[0, 0, 0]] is normalized to [0, 0, 0, 0] before Foo<T> is applied to the tuple. But in the T2 example, the tuple is split into two parts and the indices now shift.

    I can see how that is a bit surprising, but on the other hand, getting rid of the transformation has significant drawbacks. So, for now I'm going to say this is working as intended.

  4. added
    Working as IntendedThe behavior described is the intended behavior; this is not a bug
    and removed
    Needs InvestigationThis issue needs a team member to investigate its status.
    on Jan 12, 2022
  5. tadhgmister commented on Jan 14, 2022

    @tadhgmister
    Author

    Intersting, thanks for the further information. Knowing this is how typescript is resolving the tuple spreading I can get the correct logic with this code which still doesn't quite give the auto-magical argument inference I was hoping but is functionally correct which is always good.

    I suspect it still isn't working as I'd like because of the need to call Tail which uses infer on the generic it is trying to resolve meaning to figure out what V is you have to already know what V is, I'm honestly really impressed with how well typescript manages the resolution already but if you can't just zip tuples together then I don't think this use case will ever work exactly as intended.

    Anyway thanks again for looking into this. Really appreciate typescript and your work to make it great! :)

    type Tail<T extends unknown[]> = T extends [any, ...infer Rest] ? Rest : never;
    type Fn<P,R> = (param:P)=>R;
    /** Borrowed from SimplyTyped */
    type Prev<T extends number> = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62][T];
    
    
    type Fns_ArgDriven<Args extends unknown[], Rets extends unknown[]> = {[K in keyof Args]: Fn<Args[K], Rets[K&keyof Rets]>}
    type Fns_RetDriven<Args extends unknown[], Rets extends unknown[]> = {[K in keyof Rets]: Fn<Args[K&keyof Args], Rets[K]>}
    
    type Fns<Init, V extends unknown[], Ret> = V extends [] ? [Fn<Init, Ret>]
        : [Fn<Init, V[0]>, ...Fns_ArgDriven<V, [...Tail<V>, Ret]>] & [...Fns_RetDriven<[Init,...V], V>, Fn<V[Prev<V["length"]>], Ret>]
    
    declare function pipeline<Init, V extends unknown[], Ret>(...fns: Fns<Init, V, Ret>): Fn<Init, Ret>
    
    // first test only type annotate the first one, 2nd gets argument infered, 3rd onward only gets unknown infered for parameter type
    // as such final return is not captured correctly
    const test1 = pipeline((a:number)=>a.toExponential(), 
                            (b)=>b.toUpperCase(), 
                        //  ^?
                            (c)=>({foo:c}))   
                        //  ^?
    
    // second test, annotate first and third, now 2nd is set to unknown
    // gives type error that methods can't be used on unknown but return is correct
    const test2 = pipeline((a:number)=>a.toExponential(), 
                            (b)=>b.toUpperCase(), 
                        //  ^?
                            (c: string)=>({foo:c}))   
                        //  ^?
    
    declare const should_very_much_work: [Fn<1,2>, Fn<2,3>, Fn<3,4>]
    const yay_it_works = pipeline(...should_very_much_work)
    //                    ^?
  6. typescript-bot commented on Jan 16, 2022

    @typescript-bot
    Contributor

    This issue has been marked 'Working as Intended' and has seen no recent activity. It has been automatically closed for house-keeping purposes.

  7. locked as resolved and limited conversation to collaborators on Oct 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Working as IntendedThe behavior described is the intended behavior; this is not a bug

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions