Search code examples
typescript

Assigning to mapped type using generic function


I have a simple type union and associated mapped type. I'm trying to write to the mapped type, but in doing so I get this error:

Type '{ foo: T; bar: number; }' is not assignable to type 'Thingies[T]'. Type '{ foo: T; bar: number; }' is not assignable to type 'never'. The intersection 'Thingy<"a"> & Thingy<"b">' was reduced to 'never' because property 'foo' has conflicting types in some constituents.ts(2322)

type Foo = "a" | "b";
type Thingy<T> = {
  foo: T;
  bar: number;
};

type Thingies = { [K in Foo]: Thingy<K> };
const f: Thingies = {
  a: {
    foo: "a",
    bar: 1,
  },
  b: {
    foo: "b",
    bar: 2,
  },
};

function assignThingy<T extends Foo>(foo: T) {
  f[foo] = {
    foo: foo,
    bar: 1,
  };
}

It seems the issue is that typescript doesn't know that the type of foo is the same instance of T when used to create the object and assign it to Thingies. Is there a way to get this to work?

Typescript playground: https://www.typescriptlang.org/play/?ts=4.8.4#code/C4TwDgpgBAYg9nKBeKAiAhqqAfNAjVAbgChRIoAVACwEsA7AcxAB4KA+ZKAb2KigDMEALkok+edACcRdAK4BbPBEkkAviVLho1egxoQAzpy5QA2gGko9WAgC6InYxbmO64gGM4dA8AEPajPpGKDx86CKhfALCaJgANLxREtJQAIwJfKoZUHgRiXyCcCKoBNniUiIATNlZxG7E-LJ07sA0XlDoBgY0DHSOTKxQEAAewBB0ACZG8HBsABSFDgCU3In8poW2xvnRRbtlORVpNWrEQA


Solution

  • TypeScript can't see that Thingies[K] and Thingy<K> are the same type for all K. (Caveat: that's only true when K is a single member of the Foo union, but since that's all we really care about, and the solution also has the same issue, I won't belabor the point). Perhaps it should, but it's not currently supported.

    The approach that works is to refactor the types as described in microsoft/TypeScript#47109, to switch from Thingies[K] to a distributive object type of the form {[P in K]: F<P>}[K], which is known to distribute across unions in K. We can do that like this:

    type Thingies<K extends Foo = Foo> =
      { [P in K]: Thingy<P> };
    

    Here Thingies is now generic. The only change is that we're iterating over K instead of Foo. Indeed, the type named Thingies is Thingies<Foo> (thanks to the default type argument) and therefore the same as before. But now you can write Thingies<K>[K] instead of Thingies[K] and the compiler will follow your intended logic:

    function assignThingy<K extends Foo>(foo: K) {
      const _f: Thingies<K> = f;
      _f[foo] = {
        foo: foo,
        bar: 1,
      };
    }
    

    We are allowed to widen from Thingies to Thingies<K> for any K extends Foo, because Thingies is contravariant in its type argument (see Difference between Variance, Covariance, Contravariance, Bivariance and Invariance in TypeScript). TypeScript allows that widening from f to _f. And then, _f[foo] is of type Thingies<K>[K], which is now seen as equivalent to Thingy<K>.

    Playground link to code