TypeScript: Bug with mapped types and indexing

Minimal repro:

type AB = {
    a: 'a'
    b: 'a'
}

type T1<K extends keyof AB> = { [key in AB[K]]: true }
type T2<K extends keyof AB> = T1<K>[K] // BUG: should be an error for K = 'b'

I came across this in production code and made a contrived example to show the issue. Might be related to #15756.

Note that there might be two bugs here in play, one with mapped types and one with lookup types on mapped types. Once I start pushing the types a little, I come across a wild range of unexpected behavior.

Pay attention to the last type ‘G’, where the compiler is suddenly correct again, which is probably the oddest one of all.

// Let's make a simple type

type StringA = "a"

type A = {
  a: StringA
  b: StringA
}

// so type A[S] should always resolve to "a" for any valid keys
// note that it doesn't collapse T, even though it could

type T<S extends "a" | "b"> = A[S] // expands A, but doesn't collapse the indexer

let t1: T<"a"> // t1: "a"
let t2: T<"b"> // t1: "a"

// Let's make a silly generic type that only keeps key "a"
// The compiler correctly infers that this new type is just { a: true }

type B<S extends "a" | "b"> = {[key in "a"]: true} // B = { a: true }

// Let's make the type more generic, remember that A[S] is actually just "a"
// The compiler now infers that the type of C is {}, this is plain wrong

type C<S extends "a" | "b"> = {[key in A[S]]: true} // C = {}

// Obviously, we couldn't do a lookup on such a type
// Compiler correctly complains that we can't index an empty object

type D<S extends "a" | "b"> = {}[S] // Type 'S' cannot be used to index type '{}'.

// Now let's add a lookup to the generic version
// Curiously, it doesn't fail this time, but infers 'true', how odd

type E<S extends "a" | "b"> = {[key in A[S]]: true}[S] // = true

// It even happens when we index the above type C, which the compiler claims is {}

type F<S extends "a" | "b"> = C<S>[S] // = true

// Let's also try to use this type, it will become important later
// This does exactly what's expected

let a1: F<"a"> // a: true
let b1: F<"b"> // b: true

// Now let's make it even weirder and add a general indexer
// This correctly doesn't fail as any key is now valid
// The type it infers though, is just plain weird:
// G = ({} & { [key: string]: false; })[S]

type G<S extends "a" | "b"> = (
  C<S> &
  {[key: string]: false}
)[S]

// And now the weirdest thing of all, let's use this type
// It correctly infers the result! Both of these are correct!

let a2: G<"a"> // a: true
let b2: G<"b"> // b: false

This has to be the weirdest bug I’ve run across

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Reactions: 1
  • Comments: 25 (14 by maintainers)

Most upvoted comments

@SimonMeskens I’ve put a PR https://github.com/Microsoft/TypeScript/pull/17336 fixing the issues found in this thread. It turned out that BUG 2 had a different cause and was indeed a second problem.

I had a small typo in the above example, fixed it, should say C<S>, not F<S>

And you are correct that if E is correctly discarded as an error, the rest of the bugs resulting from that behavior wouldn’t matter. I still wanted to document all the behavior as I suspect there is more than one bug at play here.

I think the example above provides ample amount of bugs, but the main one is this:

type B<S extends "a" | "b"> = {[key in "a"]: true} // B = { a: true }
type C<S extends "a" | "b"> = {[key in A[S]]: true}
type F<S extends "a" | "b"> = C<S>[S] // = true

let a1: F<"a"> // a: true
let b1: F<"b"> // b: true

How can b1 ever be true? That’s just an outright bug. Type C is exactly the same as type B, because A[S] is always "a". So type C is { a: true }. b1 tries to index with an index that doesn’t even exist on the type it’s indexing.

As strange as it may seem

type C<S extends "a" | "b"> = {[key in A[S]]: true} // C = {}

is actually correct. The reason is that S extends "a" | "b" means that S can be either

  • never
  • "a"
  • "b"
  • "a" | "b"

TypeScript plays it safe here and chooses never. The “expected” type is returned when C is instantiated with a specific value for S.

declare const x: C<"a"|"b">; // { a: true } 

Note that the same applies for S extends keyof A.

Because of https://github.com/Microsoft/TypeScript/pull/12351

The operation { [P in K]: T}[X] is equivalent to an instantiation of T where X is substituted for every occurrence of P. For example, { [P in K]: Box<T[P]> }[X] is equivalent to Box<T[X]>.

type E<S extends "a" | "b"> = {[key in A[S]]: true}[S]

is converted to just

type E<S extends "a" | "b"> = true

by applying the above rule, without doing any actual “mapping” or “selection”.

PS: You can see the rule is in action, because “extra” keys are silently ignored:

type E<S extends "a" | "b" | "extra"> = {[key in A[S]]: true}[S] // no error