TypeScript - typesave mapping types of restricted tuple generic

240 views Asked by At

In TypeScript this is not compiling:

export interface Generic<T extends string> {

}

export interface Class<T extends string[]> {
  readonly prop: { [P in keyof T]: Generic<T[P]> }
}

Particularly, the Generic<T[P]> fails with Type 'T[P]' does not satisfy the constraint 'string'.. However, since T extends string[], it can be certain, that T[P] extends string for any P in keyof T.

What am I doing wrong here?


I know I can fix the problem with a conditional type:

export interface Class<T extends string[]> {
  readonly prop: { [P in keyof T]: T[P] extends string ? Generic<T[P]> : never }
}

But I don't see, why this should be necessary.

3

There are 3 answers

0
Alex Wayne On BEST ANSWER

If you look at the full error, the third line has a big clue:

Type 'T[P]' does not satisfy the constraint 'string'.
  Type 'T[keyof T]' is not assignable to type 'string'.
    Type 'T[string] | T[number] | T[symbol]' is not assignable to type 'string'.
      Type 'T[string]' is not assignable to type 'string'.(2344)

The problem is that keyof any array type (or tuple type) will be more like string | number | symbol. And an array also has more than it's member type on those keys. For instance:

// (...items: string[]) => number
type PushFunction = string[]['push']

See this snippet. There's a lot more than just numbers in array keys:

// number | "0" | "1" | "2" | "length" | "toString"
// | "toLocaleString" | "pop" | "push" | "concat"
// | "join" | "reverse" | "shift" | "slice" | "sort"
// | "splice" | "unshift" | "indexOf"
// | ... 15 more ... | "includes"
type ArrayKeys = keyof [1,2,3]

And Generic<T> requires T to be a string, but, as shown, not all values of all keys of an array are strings.

Playground


You can fix the mapped type very simply by intersecting the array keys with number, informing typescript that you only care about the number keys (which are the array indices):

export interface Generic<T extends string> {

}

export interface Class<T extends string[]> {
  readonly prop: { [P in keyof T & number]: Generic<T[P]> }
}

Playground

0
Tomasz Gawel On

Notice that for T extends string[], keyof T includes not only numeric keys but also all methods form Array's prototype. Proof? Here it is:

type StringArrayKeys = keyof string[];
// Produces: 
// type StringArrayKeys = number | "length" | "toString" | "toLocaleString" | "pop" | 
//   "push" | "concat" | "join" | "reverse" | "shift" | "slice" | "sort" | "splice" | 
//   "unshift" | "indexOf"  | "lastIndexOf" | ... 16 more

So the easiest fix for Your example would be to replace P in keyof T with P in number:

export interface Generic<T extends string> {};

export interface Class<T extends string[]> {
    readonly prop: { [P in number]: Generic<T[P]> }
}
0
jcalz On

This is a known bug in TypeScript where the support (added in TS3.1) for mapped types over tuples and arrays does not exist inside the implementation of such mapped types; see microsoft/TypeScript#27995. It seems that according to the lead architect of TypeScript:

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).

So from the outside, when you use a mapped type on an array or tuple, the mapping preserves the array-or-tupleness of the input and only maps over numeric properties:

declare const foo: Class<["a", "b", "c"]>;
// (property) prop: [Generic<"a">, Generic<"b">, Generic<"c">]
const zero = foo.prop[0]; // Generic<"a">;
const one = foo.prop[1]; // Generic<"b">;

but on the inside, the compiler still sees P in keyof T as iterating over every key of T, including any possibly non-numeric ones.

export interface Class<T extends string[]> {
  readonly prop: { [P in keyof T]: Generic<T[P]> } // error!
}

As you note, there are workarounds for this, and these workarounds are mentioned in microsoft/TypeScript#27995. I think the best one is essentially the same as your conditional type:

export interface Class<T extends string[]> {
  readonly prop: { [P in keyof T]: Generic<Extract<T[P], string>> }
}

The other ones in there either don't work for generic types like T, or produce mapped types that are no longer true arrays or tuples (e.g., {0: Generic<"a">, 1: Generic<"b">, 2: Generic<"c">} instead of [Generic<"a">, Generic<"b">, Generic<"c">]... so I'll leave them out of this answer.

Playground link to code