Search code examples
typescript

Is it possible to get TypeScript to to understand the following generic object assignment?


Consider the below code:

type Type = 'one' | 'two' | 'three'

type TypeSelection = 'one' | 'two'

interface InnerObject<T extends Type> {
  name: string,
  type: T
}

type Obj<K extends Type> = {
  [P in K]?: InnerObject<P>
} & {
  [P in TypeSelection]: InnerObject<P>
}


const obj: Obj<Type> = {
  one: {
    name: 'a',
    type: 'one'
  },
  two: {
    name: 'a',
    type: 'two'
  }
}

function getInnerObject<T extends Type>(key: T) {
  const selectedObj: InnerObject<T> | undefined = obj[key]
  const defaultObj = obj['one']
}

Playground

We know obj[key] will return InnerObject<T> due to type parameter T being constrained to Types and key being of type T.

Despite that fact, TypeScript throws an error on this assignment: const selectedObj: InnerObject<T> | undefined = obj[key].

The error message:

Type 'InnerObject<"one"> | InnerObject<"two"> | InnerObject<"three"> | undefined' is not assignable to type 'InnerObject<T> | undefined'.
  Type 'InnerObject<"one">' is not assignable to type 'InnerObject<T>'.
    Type '"one"' is not assignable to type 'T'.
      '"one"' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Type'.(2322)

It seems it tries to cross-assign all the right hand side possibilities to all the left hand side possibilities.

Is there a way to make it understand that the assignment is fine, without type assertion?


Solution

  • I don't see great documentation about the particular thing you're seeing here (the closest I found is ms/TS#56905), but in general TypeScript doesn't know how to properly perform many type operations on generic types. Often it either defers evaluation (and then refuses to see valid things as assignable to it) or it eagerly reduces the generic type parameter to its constraint (and then erroneously allows unsafe things to happen).

    In general it should be true that (X & Y)[K] is assignable to X[K] (assuming K is known to be a key of X). And for specific X, Y, and K types, TypeScript can verify that, but that's only because it just fully evaluates the intersection and the indexed access. But when K is a generic type parameter, TypeScript cannot verify it. It defers the evaluation and leaves it as some effectively opaque thing:

    const selectedObject = obj[key];
    //    ^? const selectedObject: Obj<Type>[T]
    

    The type Obj<Type> doesn't directly encode the correlation between each key T and the type InnerObject<T>. It's only represented indirectly through the intersection. And so TypeScript fails to see it:

    const selectedObject: InnerObject<T> | undefined = obj[key]; // error!
    

    The error is what you'd get if you tried to assign obj[key] to InnerObject<K> for some generic K unrelated to key. The only way the assignment would be safe is if obj[key] were of type InnerObject<K> for every possible K, which is where it looks like "cross-assignment" stuff is happening.


    If you want your assignment to succeed, you need to help TypeScript see the operation as more direct. If you have a mapped type of the form {[P in K]: F<P>} and index into it with the key K, then TypeScript will see it as assignable to F<K>. This is effectively a distributive object type as described in microsoft/TypeScript#47109. You have the required mapped type as part of the Obj<K> definition, so you should be able to widen any Obj<K> to that part safely.

    That gives the following approach:

    function getInnerObject<T extends Type>(key: T) {
        const o: { [P in Type]?: InnerObject<P> } = obj; // widen
        const selectedObj: InnerObject<T> | undefined = o[key] // index into widened thing
        const defaultObj = obj['one']
    }
    

    First we widen obj from Obj<Type> to o of type {[P in Type]?: InnerObject<P>}. That widening is allowed because it's identical to one member of the intersection, and TypeScript does allow widening from X & Y to X. Then we index into that mapped type with the generic key of type T, so TypeScript sees the resulting value as being assignable to InnerObject<T> | undefined.

    Note that you are still holding onto the obj value for later use. The o variable was only there to perform a widening safely. You could done this without another variable using the safe widening x satisfies T as T, where satisfies checks the type and as widens it:

    type OT = { [P in Type]?: InnerObject<P> };
    const selectedObj: InnerObject<T> | undefined = (obj satisfies OT as OT)[key];
    

    Playground link to code