typescripttypescript-generics

Cannot bind method and record type in the same generic object in Typescript


I have a generic object for which I need to correlate types on two values. But Typescript fails to understand that once a value type is set, the other is necessarily determined. Therefore, it transforms a stringĀ | string[] into a string & string[] constraint on the other type. The detailed and minimal example below should make it clear.

The helper function seems key to the "bug" (if I inline it, the problem disappear).

// I work on simple objects (Doc) where values are either string or string[]
type Field = string | string[];
type Doc = Record<string, Field>;

// Some functions determine propositions of change on Doc objects and a replacement function that will implement the select proposition after the user choses a proposition. So 'propositions[0]' and second argument of 'replacer' should be of the same type, either string or string[])
type ChangeOf<T extends Field> = {
  propositions: T[];
  replacer(doc: Doc, choice: T): Doc;
};
type Change = ChangeOf<string> | ChangeOf<string[]>;
function computePropositions(): Change {
  // same function can return either ChangeOf<string> or ChangeOf<string[]>
  return {
    propositions: ["proposition1", "proposition2"],
    replacer: mapReplacer<string>("key"),
  };
}

// This is a helper function that will implement the Change on a designated key of Doc
function mapReplacer<T extends Field>(
  key: string,
): (doc: Doc, choice: T) => Doc {
  return (doc: Doc, choice: T): Doc => Object.assign(doc, { [key]: choice });
}

function run() {
  const oldDoc = { key: "val" };
  const change = computePropositions();
  const newDoc = change.replacer(oldDoc, change.propositions[0]); // expect {key: "proposition1"}
}

Type-checking fails on change.propositions[0] of the last line of run() with

Argument of type 'string | string[]' is not assignable to parameter of type 'string & string[]'.
  Type 'string' is not assignable to type 'string & string[]'.
    Type 'string' is not assignable to type 'string[]'.

I tried inlining mapReplacer and it solves the problem but this is not satisfactory as I need it a lot. I tried using an arrow function value instead of a method for the replacer definition.

Help would be greatly appreciated !

Playground link


Solution

  • If you have a value of a union type, like change of type Change, then TypeScript can't see that multiple utterances of that value are correlated to each other. In the line

    change.replacer(oldDoc, change.propositions[0])
    

    the type of change could either be ChangeOf<string> or ChangeOf<string[]>. In each case, the line is valid. But TypeScript does not analyze a single line of code multiple times, for each possible narrowing of change. It analyzes it "at once", treating each mention of change as if it's some independent union-typed variable. It's as if you had

    change1.replacer(oldDoc, change2.propositions[0])
    

    where change1 and change2 were both of type Change. That would be unsafe, and TypeScript rightly warns about that. It doesn't see the difference between those two lines, though.

    In other words, TypeScript doesn't directly support correlated union types, as described in microsoft/TypeScript#30581.


    The recommended approach in cases of correlated union types is to refactor to use generics that are constrained to unions instead of plain unions. The specifics are described in microsoft/TypeScript#47109.

    The general idea is to write everything in terms of a "base" interface, mapped types over that interface, and generic indexes into those types.

    One wrinkle in your case is that your union isn't a discriminated union. There's no literal type you can key off of to distinguish ChangeOf<string> from ChangeOf<string[]>, and the refactoring depends on such a key existing.

    We can add a key ourselves, but it's not used anywhere in your code, so it's not ideal. Still, it looks like this. The base interface is:

    interface FieldMap {
      string: string;
      stringArr: string[]
    }
    

    (the string and stringArr keys are the phantom keys we'll use to distinguish union members). Then we can combine ChangeOf and Change into a single Change generic type like this:

    type Change<K extends keyof FieldMap = keyof FieldMap> = { [P in K]: {
      propositions: FieldMap[P][];
      replacer(doc: Doc, choice: FieldMap[P]): Doc;
    } }[K]
    

    Here Change<K> is a mapped type over K into which we immediately index with K. That makes Change<K> a distributive object type, so that Change<K1 | K2> will be equivalent to Change<K1> | Change<K2>. Here Change<"string"> is equivalent to your ChangeOf<string>, and Change<"stringArr"> is equivalent to your ChangeOf<string[]>. Since K defaults to "string" | "stringArr", then Change without a type argument is equivalent to your Change.

    Now we can rewrite mapReplacer and computePropositions to use the new types:

    function mapReplacer<K extends keyof FieldMap>(
      key: string,
    ): (doc: Doc, choice: FieldMap[K]) => Doc {
      return (doc: Doc, choice: FieldMap[K]): Doc => Object.assign(doc, { [key]: choice });
    }
    
    function computePropositions(): Change {
      return {
        propositions: ["proposition1", "proposition2"],
        replacer: mapReplacer<"string">("key"),
      };
    }
    

    Here mapReplacer is now generic in the phantom key type, so we call it with "string" instead of string. And we refer to FieldMap[K] to get string from "string" or string[] from "stringArr".

    And now run() needs to be generic also, at least in part:

    function run() {
      const oldDoc = { key: "val", "key[]": ["val1", "val2"] };
      const change = computePropositions();
      function runReplacer<K extends keyof FieldMap>(change: Change<K>) {
        return change.replacer(oldDoc, change.propositions[0])
      }
      const newDoc = runReplacer(change);
    }
    

    In order for change.replacer(oldDoc, change.propositions[0]) to work, the type of change needs to be a generic Change<K>. That's the whole point of this refactoring. Since run() itself isn't generic, we need to make a generic function inside it and call it. So runReplacer() is generic in K. The line inside the function works, and we can call it with a change of type Change.

    So there you go.


    I don't know if such a refactoring is really worth it. If you only allow string or string[] then it's not so bad. If you allow lots of different kinds of fields, then you might be better off with a refactoring that emulates existentially quantified generic types. TypeScript doesn't directly have such types (and neither do most languages with generics), although they are requested at microsoft/TypeScript#14466. They can be emulated, though. I'll quickly show how it looks without going into how it works because it's possibly a digression from the point of the question:

    type SomeChange = <R>(cb: <T>(change: ChangeOf<T>) => R) => R;
    const someChange = <T,>(change: ChangeOf<T>): SomeChange => cb => cb(change);
    
    function computePropositions(): SomeChange {
      return someChange({
        propositions: ["proposition1", "proposition2"],
        replacer: mapReplacer<string>("key"),
      });
    }
    
    function run() {
      const oldDoc = { key: "val" };
      const sc = computePropositions();
      const newDoc = sc(change => change.replacer(oldDoc, change.propositions[0]));
    }
    

    Essentially we're hiding the generic type for ChangeOf<T> and only allowing people to access it through a callback function of type SomeChange. That lets you get a Doc out of a SomeChange without needing to know what T is.

    Playground link to code