Search code examples
typescripttypescript-genericstype-inferencenarrowing

Infer narrow type from parent object referencing itself in typescript


I would like to constrain a parameter type of a callback function that is inside an object like this:

makeObj({
  items: [
    {
      value: "foo",
      func(items) {}
    },
    {
      value: "bar",
      func(items) {}
    }
  ]
});

I want to constrain the parameter items of func to "foo" | "bar". The values of value can be any string though.

I already have this, but it obviously doesn't work:

interface MyObject<T extends string, Values extends readonly MyObject<T, Values>[]> {
  value: T;
  func: (items: Values) => void;
}
interface Items<T extends string, Data extends readonly MyObject<T, Data>[]> {
  items: readonly [...Data];
}
function makeObj<T extends string, Data extends readonly MyObject<T, Data>[]>(opt: Items<T, Data>) {
  return opt;
}

makeObj({
  items: [
    {
      value: "foo",
      func(items) {}
/* Error on "func":
Type '(items: MyObject<string, unknown>) => void' is not assignable to type '(items: unknown) => void'.
  Types of parameters 'items' and 'items' are incompatible.
    Type 'unknown' is not assignable to type 'MyObject<string, unknown>'.
*/
    },
    {
      value: "bar",
      func(items) {} // same error on "func"
    }
  ]
});

Typescript Playground


Some background info: I'm writing a program that has some "actions" defined. Each action is a function in an object to describe the function (give it a name, description, input type, output type, secondary input type). The secondary input relies on user provided data. Each item of this data has a name and defined type. Its value is provided by the end user. Depending on the value of other items an item can be displayed to the user or not. This last part is controlled by the callback function in question.
I have all this already set up with narrow type inference to not make any mistakes while writing each action but this narrow type inference breaks down regardless of which type the callback function has. Either because it can't infer the narrow type anymore, or because I get a type error (e.g. when I use object as type for the parameter (I ultimately want to use an object as the parameter, not just a string)). This narrow type inference works like this: typescript - Infer/narrow function argument from sibling property


Edit:

I would like a solution where I don't have the type embedded in the function parameter but as an interface/type that I can reference like:

interface Magic<MagicParam> {
  items: MagicParam;
}
makeObj<MagicParam>(opt: Magic<MagicParam>) {
  return opt;
}

Magic can be an interface or type and can have any number of type parameters. makeObj too can have any number of type parameters.


Solution

  • Your makeObj() function could be generic in the union of string literal types corresponding to the value properties of the function parameter's items property. If we call that generic type parameter K, and if you pass in, for example, {items: [{value: "x"},{value: "y"},{value: "z"}]} (ignoring func for now), then K should be the "x" | "y" | "z"

    Then you can express the argument to makeObj() in terms of K. If we call that type Opt<K>, we can write it like this:

    interface Opt<K extends string> {
      items: Array<{ value: K, func: (items: K) => void }>
    }
    

    That means, given K of value properties, we want the items property of Opt<K> to be an array, where the elements of the array are objects with a value property of type K, and a func callback property whose input is type K.

    Let's make sure that's what you want, by evaluating Opt<"foo" | "bar"> (and using conditional type inference and a mapped type to coax the compiler into displaying details of the structure):

    type Test = Opt<"foo" | "bar"> extends
      infer O ? { [K in keyof O]: O[K] } : never 
    
    /* type Test = {
        items: {
            value: "foo" | "bar";
            func: (items: "foo" | "bar") => void;
        }[];
    } */
    

    Looks good.


    Anyway, as mentioned above, makeObj will be generic in K and take a parameter of type Opt<K>:

    function makeObj<K extends string>(opt: Opt<K>) {
      return opt;
    }
    

    And let's see if it works:

    const obj = makeObj({
      items: [
        {
          value: "foo",
          func(items) {
            // (parameter) items: "foo" | "bar"
          }
        },
        {
          value: "bar",
          func(items) {
            // (parameter) items: "foo" | "bar"
          }
        }
      ]
    });
    // const obj: Opt<"foo" | "bar">
    

    Looks good too. The compiler infers that K is "foo" | "bar", and then contextually types the items callback parameters of the func property to also be "foo" | "bar". And the obj that comes out is of the desired Opt<"foo" | "bar"> type.

    Playground link to code