Search code examples
typescripttypescript-genericstypescript-types

TypeScript: How to use generated Union Type correctly in a loop?


I have the following type:

type Updater<T> = {
  [K in keyof T]: {
    key: K;
    update: (value: string) => T[K];
  };
}[keyof T]

Here my intention is to have some generic updaters for any type that takes an input and update each individual property with an update function, which has type enforced so the output matches with the type of the property. This purpose is served:

type TestType = {
  fieldA: string;
  fieldB: number;
}

type TestTypeUpdater = Updater<TestType>[];

const testTypeUpdater: TestTypeUpdater = [
  {
    key: "fieldA", // enforced to be one of "fieldA" or "fieldB"
    update: s => s + "(updated)" // enforced to output the type of "fieldA"
  },{
    key: "fieldB",
    update: s => s.length      // enforced to output the type of "fieldB"
  }
];

However, when I try to use it, it is not as I expected. What I wanted to do is:

const testType: TestType = {
  fieldA: "",
  fieldB: 0
}

function test(input: string) {
  for (const u of testTypeUpdater) {
    testType[u.key] = u.update(input);
  }
}

test("input");

I was expecting it is as simple as the above, but TypeScript is complaining under testType[u.key]:

Type 'string | number' is not assignable to type 'never'.
  Type 'string' is not assignable to type 'never'.ts(2322)

It seems the fact that the return type of update is T[K] is lost but becomes a union of string | number. What I had to do to stop that was:

function testOK(input: string) {
  for (const u of testTypeUpdater) {
    if (u.key === "fieldA") {
      testType[u.key] = u.update(input);
    } else if (u.key === "fieldB") {
      testType[u.key] = u.update(input);
    }
  }
}

testOK("input");

The fact that I need to repeat again each property name in the type in the loop defeats the whole purpose to generate the union type with keyof.

How do you make the version of the loop without needing to check for the property name works?


Added: What if the Updater is a dictionary type, e.g.:

type Updater<T> = {
  [K in keyof T]: {
    update: (value: string) => T[K];
  };
}

type TestType = {
  fieldA: string;
  fieldB: number;
}

type TestTypeUpdater = Updater<TestType>;

const testTypeUpdater: TestTypeUpdater = {
  fieldA: { // enforced to be one of "fieldA" or "fieldB"
    update: s => s + "(updated)" // enforced to output the type of "fieldA"
  },
  fieldB: {
    update: s => s.length      // enforced to output the type of "fieldB"
  }
};

const testType: TestType = {
  fieldA: "",
  fieldB: 0
}

function test(input: string) {
  Object.keys(testTypeUpdater).forEach(k => {
    testType[k] = testTypeUpdater[k].update(input);
  });
}

test("input");

Solution

  • You've run into an issue I call "correlated union types", as discussed in microsoft/TypeScript#30581. Many cases of this have been addressed by indexed access inference improvements as implemented in microsoft/TypeScript#47109, although it tends to require some refactoring.


    The problem with

    function test(input: string) {
      for (const u of testTypeUpdater) {
        testType[u.key] = u.update(input);
      }
    }
    

    is that the compiler cannot track the correlation between the type of u.key and the type of u.update(input). This is what it sees:

    const uKey = u.key; // "fieldA" | "fieldB"
    const uUpdated = u.update(input) // string | number
    testType[uKey] = uUpdated; // error!
    

    Note how each of uKey and uUpdated are of union types. These types aren't wrong; it's true that uKey could be either of those two string literals, and that uUpdated could be either a string or a number. But it's treating these as independent or uncorrelated types. As far as the compiler can see from these types, uKey might be "fieldA" while uUpdated might be number. And so the assignment fails.

    This inability to track union correlations is the subject of microsoft/TypeScript#30581. You can work around it either by writing redundant code, as you've shown... or by using type assertions to suppress the error:

    testType[uKey] = uUpdated as never; // fixed I guess?
    

    But... you can also refactor using generics and indexed access types. The basic idea is to use a distributive object type as coined in microsoft/TypeScript#47109. Here's what we can do to Updater<T>:

    type Updater<T, K extends keyof T = keyof T> = { [P in K]: {
        key: P;
        update: (value: string) => T[P];
    }; }[K]
    

    So the type Updater<T> is the same as before, but you can narrow it to just one key K of T by specifying it as Updater<T, K>. For example:

    type TestTypeUpdaterEither = Updater<TestType>;
    /* type TestTypeUpdaterEither = {
        key: "fieldA";
        update: (value: string) => string;
    } | {
        key: "fieldB";
        update: (value: string) => number;
    } */
    
    type TestTypeUpdaterFieldA = Updater<TestType, "fieldA">
    /* type TestTypeUpdaterFieldA = {
        key: "fieldA";
        update: (value: string) => string;
    } */
    

    And now, we change your for..of loop to a forEach() call, so we can pass in a callback which is generic in K, some particular key of TestType. The compiler allows assignments of TestType[K] to the generic K property of a TestType value. (This is actually unsafe too, if K is specified with a union, but the compiler permits this and we need to use it in order to proceed. And we won't actually be doing anything unsafe with it.)

    It looks like this:

    testTypeUpdater.forEach(<K extends keyof TestType>(u: Updater<TestType, K>) => {
        const uKey = u.key; // K
        const uUpdated = u.update(input) // TestType[K]
        testType[uKey] = uUpdated; // okay
    }
    

    It works! Oh, and I guess we don't need to break those apart into individual variables:

    testTypeUpdater.forEach(<K extends keyof TestType>(u: Updater<TestType, K>) => {
        testType[u.key] = u.update(input); // okay
    })
    

    Still works.

    Playground link to code