Typescript: Mapped tuples types iterates over all properties

Created on 19 Oct 2018  路  23Comments  路  Source: microsoft/TypeScript

TypeScript Version: 3.2.0-dev.20181019

Search Terms: mapped tuples reify

Code

type Foo = ['a', 'b'];
interface Bar
{
    a: string;
    b: number;
}

type Baz = { [K in keyof Foo]: Bar[Foo[K]]; }; // Expected Baz to be [string, number]

Expected behavior: Baz should be [string, number]

Actual behavior: Type '["a", "b"][K]' cannot be used to index type 'Bar'.

Playground Link: https://www.typescriptlang.org/play/index.html#src=type%20Foo%20%3D%20%5B'a'%2C%20'b'%5D%3B%0D%0Ainterface%20Bar%0D%0A%7B%0D%0A%09a%3A%20string%3B%0D%0A%09b%3A%20number%3B%0D%0A%7D%0D%0A%0D%0Atype%20Baz%20%3D%20%7B%20%5BK%20in%20keyof%20Foo%5D%3A%20Bar%5BFoo%5BK%5D%5D%3B%20%7D%3B%20%2F%2F%20Expected%20Baz%20to%20be%20%5Bstring%2C%20number%5D%0D%0A%0D%0Atype%20WorkingBaz%20%3D%20%7B%20%5BK%20in%20Exclude%3Ckeyof%20Foo%2C%20keyof%20any%5B%5D%3E%5D%3A%20Foo%5BK%5D%20extends%20keyof%20Bar%20%3F%20Bar%5BFoo%5BK%5D%5D%20%3A%20never%3B%20%7D%20%26%20%7B%20length%3A%20Foo%5B'length'%5D%3B%20%7D%20%26%20any%5B%5D%3B

Related Issues: https://github.com/Microsoft/TypeScript/issues/25947

Given the Mapped tuple types feature (#25947). I'd expect the code above to work cleanly.

However, I still need to do:

type WorkingBaz = { [K in Exclude<keyof Foo, keyof any[]>]: Foo[K] extends keyof Bar ? Bar[Foo[K]] : never; } & { length: Foo['length']; } & any[];

To have an equivalent type. As far as I understand, the "K" in a mapped type on a tuple should iterate only on numeric keys of this tuple. Therefore, Foo[K] should always be a valid key for Bar...

Bug Mapped Types

Most helpful comment

@ksaldana1 Thanks, nice idea!
Actually my own WorkingBaz is a pretty good workaround as well, if I can say so myself:

type WorkingBaz = { [K in Exclude<keyof Foo, keyof any[]>]: Bar[Foo[K]]; } & { length: Foo['length']; } & any[]

It avoids the drawback of losing the Array prototype's methods (only their typing info, which are already much less useful on heterogeneous tuples...).
Although it's not recognized as [string, number], as far as I can tell, it's still an almost structurally equivalent type:
{ "0": string; "1": number; } & { length: 2; } & any[].

But I'd still expect this to work out of the box and produce the correct [string, number] type.

All 23 comments

This is because, despite mapped types not behaving as such, keyof ["some", "tuple"] still returns 0 | 1 | "length" | ... | "whatever" instead of 0 | 1.

I wouldnt mind a fix for this as well. I've been encountering similar issues, but here's how I've been working around it for anyone else with this problem: (my code generalized below with Boxes)

interface Box<T> {
  value: T;
}

type UnBox<T extends Box<unknown>> = T extends Box<infer U> ? U : never;

type UnBoxTuple<T extends Box<unknown>[]> = {
  [P in keyof T]: UnBox<T[P]>;
};

Above code complains that T[P] does not satisfy the constraint Box<unknown>. My current fix has been just to manually narrow the type with a conditional like so: (Edit: looks like this format is required for using mapped tuples like this, #27351)

type UnBoxTuple<T extends Box<unknown>[]> = {
  [P in keyof T]: T[P] extends T[number] ? UnBox<T[P]> : never;
};

Trying to apply the same to @lemoinem's example didn't work at first. It seems like the mapped tuple special-case only applies to generics (try the following in ts 3.1.3 to see what I mean):

type Foo = ["a", "b"];

type GenericTupleIdentity<T extends unknown[]> = { [K in keyof T]: T[K] };
type FooIdentity = { [K in keyof Foo]: Foo[K] }

type GenericTupleIdentityTest = GenericTupleIdentity<Foo> // ["a", "b"]
type FooIdentityTest = FooIdentity // a Foo-like object no longer recognised as a tuple

Not sure if this inconsistency is intended or not. Abstracting-out Foo and Bar and adding a manual type-narrower gets us:

type Bazify<B, F extends (keyof B)[]> = {
  [K in keyof F]: F[K] extends F[number] ? B[F[K]] : never;
};

Which can then be called with Bazify<Bar, Foo> (or use type Baz = Bazify<Bar, Foo>; to keep existing-code-changes to a minimum)

It's worth noting that T[P] extends T[number] is used instead of simply P extends number because for some reason P acts like a pre 2.9 key and always extends string ("0" | "1" | "length" | ...) as opposed to string | number | symbol (0 | 1 | "length" | ...), which is probably a separate issue of its own.

The issue here is that we only map to tuple and array types when we _instantiate_ a generic homomorphic mapped type for a tuple or array (see #26063). We should probably also do it for homomorphic mapped types with a keyof T where T is non-generic type.

Just stumbled on what I assume is the same issue:

type n1 = [1, 2, 3]
type n2t<T> = {[k in keyof T]: 4}

type n2 = { [k in keyof n1]: 4 } // { length: 4, find: 4, toString: 4, ...}
type n2b = n2t<n1> // [4, 4, 4] as expected

I would expect n2 to behave like n2b.

This also means that these constructs don't work:

type Test1<S extends number[]> = Record<number, "ok">[S[keyof S]];

type Test2<S extends [0, 1, 2]> = Record<number, "ok">[S[keyof S]];

The second one should definitely work. This means that it's impossible to map tuples to other tuples with a method that requires the value type of the input tuple to be known to extend something (e.g. number as above)

@lemoinem You can get your current example to narrow correctly and avoid the behavior @weswigham mentioned with a couple of helper conditional types. Note: this comes with some not so great drawbacks.. You lose all array prototype methods and properties, which for my current use case--messing with React hooks--that tradeoff isn't the worst thing in the world.

export type ArrayKeys = keyof any[];
export type Indices<T> = Exclude<keyof T, ArrayKeys>;

type Foo = ['a', 'b'];

interface Bar {
  a: string;
  b: number;
}

type Baz = { [K in Indices<Foo>]: Bar[Foo[K]] }; // type is { "0": string, "1": number }

The way this type ends up formatting isn't the greatest, but it ultimately maps to some of your desired outcomes. I am having issues around some constructs I thought would work, similar to @phiresky 's examples. I will get some better examples together and document those, but for now just wanted to provide a bit of a workaround.

@ksaldana1 Thanks, nice idea!
Actually my own WorkingBaz is a pretty good workaround as well, if I can say so myself:

type WorkingBaz = { [K in Exclude<keyof Foo, keyof any[]>]: Bar[Foo[K]]; } & { length: Foo['length']; } & any[]

It avoids the drawback of losing the Array prototype's methods (only their typing info, which are already much less useful on heterogeneous tuples...).
Although it's not recognized as [string, number], as far as I can tell, it's still an almost structurally equivalent type:
{ "0": string; "1": number; } & { length: 2; } & any[].

But I'd still expect this to work out of the box and produce the correct [string, number] type.

I'm also seeing the same bug. I was about to log an issue, but I saw this one in the related issues page.

For reference, the code I'm experiencing this bug with:
Search Terms:
tuple mapped type not assignable
Code

type Refinement<A, B extends A> = (a: A) => a is B
// we get the error `Bs[k] does not satisfy the constraint 'A'. Type A[][k] is not assignable to type A`
type OrBroken = <A, Bs extends A[]>(...preds: { [k in keyof Bs]: Refinement<A, Bs[k]> }) => Refinement<A, Bs[number]>
// same (well similar, [A, A, A][k] is not assignable vs A[][k] not assignable) error
type OrBroken1 = <A, Bs extends [A, A, A]>(...preds: { [k in keyof Bs]: Refinement<A, Bs[k]> }) => Refinement<A, Bs[number]>
// if we don't map over the type we don't receive the error
type OrWorks = <A, Bs extends A[]>(...preds: { [k in keyof Bs]: Refinement<A, Bs[0]> }) => Refinement<A, Bs[number]>
type OrWorks1 = <A, Bs extends A[]>(...preds: { [k in keyof Bs]: Refinement<A, Bs[number]> }) => Refinement<A, Bs[number]>

Expected behavior:
When mapping over a tuple type Bs where every element extends some type A, the mapped type Bs[k] should be assignable to A. In other words, OrBroken in the above example should not give an error
Actual behavior:
We recieve the error Bs[k] does not satisfy the constraint 'A'. Type A[][k] is not assignable to type A

Playground Link:
link

You could just filter for number properties:
``` ts
type Foo = ['a', 'b'];
interface Bar
{
a: string;
b: number;
}

// Inverse of Exclude<>
type FilterOnly = T extends N ? T : never;

type Baz = {
};

I think we have enough and various workarounds and comments from the TS team identifying the root issue.
If you have the same issue, may I suggest you add a thumbs up/+1 reaction to the initial comment (and subscribe to the issue so you will know when this is finally fixed) instead of commenting "Me too".

This will prevent spamming everyone in the thread. Thank you very much.
(I'm still eagerly waiting for a fix to this ;) )

Hey there, I'm running into this issue, except I'm using a generic tuple type instead of a statically declared tuple.

type PropertyType = "string" | "number" | "boolean"

type PropertyValueTypeMap = {
    string: string, 
    number: number, 
    boolean: boolean
}

type Args<A extends Array<PropertyType>> = {
    // ERROR: Type 'A[I]' cannot be used to index type 'PropertyValueTypeMap'.
    [I in keyof A]: PropertyValueTypeMap[A[I]] 
}

// RESULT: type A = [string, number]
type A = Args<["string", "number"]>

playground

@augustobmoura's FilterOnly approach doesn't work.
@ksaldana1's Exclude<keyof Foo, keyof any[]> approach also does not work.

Considering that this works:

type Args<A extends Array<PropertyType>> = {
    0: PropertyValueTypeMap[A[0]]
    1: PropertyValueTypeMap[A[1]]
    2: PropertyValueTypeMap[A[2]]
} 

... I would have expected this to work, but it doesn't:

type Args<A extends Array<PropertyType>> = {
    [K in keyof A]: K extends number ? PropertyValueTypeMap[A[K]] : never
} 

Also tried this...

type Args<A extends Array<PropertyType>> = {
    [K in keyof A]: K extends keyof Array<any> ?  never : PropertyValueTypeMap[A[K]]
} 

@ccorcos I just had the same problem, solved it like this

type PropertyType = "string" | "number" | "boolean"

type PropertyValueTypeMap = {
    string: string, 
    number: number, 
    boolean: boolean
}

type Args<A extends Array<PropertyType>> = {
    [I in keyof A]: A[I] extends PropertyType ? PropertyValueTypeMap[A[I]] : never;
}

// RESULT: type A = [string, number]
type A = Args<["string", "number"]>

I've frequently been bitten by this. It's come up enough for me that I now usually avoid using mapped types with tuples and instead resort to arcane recursive types to iterate over them. I'm not a fan of doing this, but it's proven to be a much more reliable means of tuple transformations. 馃槩

@ahejlsberg from your comment it's not clear how complex this is to solve. Is the fix simple?

If the team is looking to get help from the community on this one, it may help to have a brief outline of the necessary work if possible (e.g. relevant areas of the compiler, any necessary prework, gotchas, etc). 馃檪

Just as an aside... Flow has an entirely separate utility type for mapping over arrays/tuples. Given TypeScript has gone with a syntatic approach to mapped types thus far, I can't help but wonder: Should we have separate, dedicated syntax for array/tuple mapping? 馃

An example might be simply using square brackets instead of curlies, e.g. [[K in keyof SomeTuple]: SomeTuple[K]]

I just can't shake the feeling that trying to specialise the behaviour of the existing mapped types for arrays/tuples may create more problems than it solves. This very issue is a consequence of trying to do it. There are also cases like "I want to map over a tuple type from an object perspective" that don't have clear answers to me, but that's an entirely separate discussion.

Anyway, I'm not sure if dedicated syntax has already been considered and rejected. I'm more just throwing it out there in case it hasn't. 馃檪

@ccorcos I just had the same problem, solved it like this

type PropertyType = "string" | "number" | "boolean"

type PropertyValueTypeMap = {
  string: string, 
  number: number, 
  boolean: boolean
}

type Args<A extends Array<PropertyType>> = {
    [I in keyof A]: A[I] extends PropertyType ? PropertyValueTypeMap[A[I]] : never;
}

// RESULT: type A = [string, number]
type A = Args<["string", "number"]>

Further that @tao-cumplido would it be possible to extend that so that the length of infered from a keyof? eg:

type Things = 'thing-a' | 'thing-b';

type Test = Args<string, Things>; // [string, string]
type Test2 = Args<string | number, Things>; // [string | number, string | number ]
type Test2 = Args<string, Things | 'thing-3'>; // [string string, string]

declare const test:Args<string, Things> = ['a', 'b']; 

I've been trying to come up with a way to map over a type T that could be an array _or_ a tuple, without inserting an extra conditional over T[i], which can get in the way. Here's what I've got:

interface Box<V = unknown> {
    value: V
}
function box<V>(value: V) {
    return { value }
}

type Unbox<B extends Box> = B['value']

type NumericKey<i> = Extract<i, number | "0" | "1" | "2" | "3" | "4"> // ... etc

type UnboxAll<T extends Box[]> = {
    [i in keyof T]: i extends NumericKey<i> ? Unbox<T[i]> : never
}

declare function unboxArray<T extends Box[]>(boxesArray: T): UnboxAll<T>
declare function unboxTuple<T extends Box[]>(...boxesTuple: T): UnboxAll<T>

const arrayResult = unboxArray([box(3), box('foo')]) // (string | number)[]
const tupleResult = unboxTuple(box(3), box('foo')) // [number, string]

So far, I haven't been able to find a variant that works for any numeric key, without the hardcoding ("0" | "1" | "2" | ...). Typescript is very sensitive about what goes into the mapped type in UnboxAll. If you mess with the [i in keyof T] part at all, it stops being to instantiate arrays and tuples with the mapped type. If you try to use keyof T in the conditional that begins i extends..., or do anything too fancy, TypeScript is no longer convinced that T[i] is a Box, even if you are strictly narrowing i.

As stated before, I am specifically trying to avoid testing T[i] extends Box, because in my case this conditional appears downstream as unevaluated. Really if T extends Box[] and I'm using a homomorphic mapped type to transform arrays and tuples, there should be a way to use T[i] and have it be known to be a Box without introducing a conditional. Or, it should be at least be possible/convenient to extract the suitable indices so that the conditional can be on the indices.

Actually, the following totally works for my purposes:

interface Box<V = unknown> {
    value: V
}
function box<V>(value: V) {
    return { value }
}

type Unbox<B extends Box> = B['value']

type UnboxAll<T extends Box[]> = {
    [i in keyof T]: Unbox<Extract<T[i], T[number]>> // !!!
}

declare function unboxArray<T extends Box[]>(boxesArray: T): UnboxAll<T>
declare function unboxTuple<T extends Box[]>(...boxesTuple: T): UnboxAll<T>

const arrayResult = unboxArray([box(3), box('foo')]) // (string | number)[]
const tupleResult = unboxTuple(box(3), box('foo')) // [number, string]

I just needed to test T[i] inside the call to Unbox<...> to avoid an extra conditional that could stick around unevaluated. I'm aware that Extract is itself a conditional. Somehow this code works out better (for reasons not visible in the example) than if I wrote T[i] extends T[number] ? Unbox<T[i]> : never. I hope this helps someone else.

This is still something that irks me daily, because I have a lot of code that maps over array types, whether or not they are tuples. When mapping over X extends Foo[], you can't assume X[i] is a Foo. You need a conditional, every time, making the code more verbose.

For example:

type Box<T = unknown> = { value: T }

type Values<X extends Box[]> = {
    [i in keyof X]: Extract<X[i], Box>['value'] // Extract is needed here
}

type MyArray = Values<{value: number}[]> // number[]
type MyTuple = Values<[{value: number}, {value: string}]> // [number, string]

It's worth noting that the behavior shown in MyArray and MyTuple is already magical. TypeScript is not literally transforming every property, just the positional ones.

While I have no knowledge of the internals here, my guess is that the actual mapping behavior could be changed rather easily; the problem is getting X[i] to have the appropriate type.

It would be really nice if this code would just work:

type Values<X extends Box[]> = {
    [i in keyof X]: X[i]['value']
}

The question is, presumably, on what basis can i be taken to have type number rather than keyof X to the right of the colon? And then, when we go to do the actual mapping on some X, do we somehow make sure to always ignore the non-positional properties, in order to be consistent with this? (Note that the current behavior is already pretty weird if the type passed as X is an array that also has non-positional properties, so I don't think we need to worry overly much about that case.)

Here is perhaps a novel idea, not sure if it is useful, but I wonder if it would be harder or easier to modify the compiler so that this code works for mapping arrays and tuples:

type Values<X extends Box[]> = {
    [i in number]: X[i]['value']
}

As before, the intent would be for this to work on pure arrays and pure tuples X, and do something sensible on non-pure types. It seems like there is no more problem of making sure i has the correct type to the right of the colon, here. The problem would presumably be recognizing this pattern as mapping over X.

Alternatively, just to throw it out there, there could be a new keyword posof that means "positional properties of." Then you could write[i in posof X]. I'm not sure introducing a new keyword is the right solution, but I thought I'd mention it. Presumably it would solve any difficulties of i having the right type and doing the array and tuple mapping without having to impose new semantics on existing mapped types that might break compatibility or be too complex.

@ksaldana1 Thanks, nice idea!
Actually my own WorkingBaz is a pretty good workaround as well, if I can say so myself:

type WorkingBaz = { [K in Exclude<keyof Foo, keyof any[]>]: Bar[Foo[K]]; } & { length: Foo['length']; } & any[]

It avoids the drawback of losing the Array prototype's methods (only their typing info, which are already much less useful on heterogeneous tuples...).
Although it's not recognized as [string, number], as far as I can tell, it's still an almost structurally equivalent type:
{ "0": string; "1": number; } & { length: 2; } & any[].

But I'd still expect this to work out of the box and produce the correct [string, number] type.

Trying to generalize this (at least the first part):

export type ArrayKeys = keyof unknown[];
export type TupleKeys<T extends unknown[]> = Exclude<keyof T, ArrayKeys>;
type PickTuple<T extends Record<string, any>, TupleT extends Array<keyof T>> = {
  [K in TupleKeys<TupleT>]:T[TupleT[K]];
};

gives Type 'TupleT[K]' cannot be used to index type 'T'

But this seems to workaround that:

export type ArrayKeys = keyof unknown[];
export type TupleKeys<T extends unknown[]> = Exclude<keyof T, ArrayKeys>;
type PickTuple<T extends Record<string, any>, TupleT extends Array<keyof T>> = {
  [K in TupleKeys<TupleT>]:
    TupleT[K] extends keyof T ? T[TupleT[K]] : never;
};

giving:

type PickTuple<T extends Record<string, any>, TupleT extends Array<keyof T>> = {
  [K in TupleKeys<TupleT>]:
    TupleT[K] extends keyof T ? T[TupleT[K]] : never & { length: TupleT['length']; } & unknown[];
};

EDIT: I just saw this comment which provides something similar but manages to keep the tuple (since it filters for number properties like mentioned here):

type Bazify<B, F extends (keyof B)[]> = {
  [K in keyof F]: F[K] extends F[number] ? B[F[K]] : never;
};

I just discovered this potential workaround as of TypeScript 4.1:

type BoxElements<Tuple extends readonly unknown[]> = {
  [K in keyof Tuple]: K extends `${number}` ? Box<Tuple[K]> : Tuple[K]
};

I haven't tested it extensively, but seems to work for my current use case! 馃檪

@ahejlsberg

We should probably also do it for homomorphic mapped types with a keyof T where T is non-generic type.

And maybe also where T is a generic type constrained to an array/tuple type like T extends Array<X>?

Was this page helpful?
0 / 5 - 0 ratings