Search code examples
typescripttypescript-genericstypescript-types

How to extend narrow type inference to every property of an object?


Suppose a "dependent function" (type DepFunc) is a simple object with property deps whose value is an arbitrary simple object (the keys of which are the "dependencies"), and a property build whose value is a function that takes an object with the same keys as deps and returns a function from number to number; OR a dependent function could be just a function from number to number in the trivial case of no dependencies. This type seems well captured by

type DepFunc<Keys extends string> 
  = { deps: {[K in Keys]: true}, 
      build: (dep: {[K in Keys]: number}) => (n: number) => number
    } | ((n: number) => number)

And indeed, I can now define a function that takes a dependent function, e.g.

function foo<Keys extends string>(arg: DepFunc<Keys>) {
   return arg
}

const r = foo({deps: {p: true}, build: dep => n => n + dep.p})
const s = foo(n => n + 1)

and we get useful type checking: if the dep.p is changed to dep.q in the first call to foo, the compiler helpfully complains that property q does not exist on the type of dep.

But what I would really like is to have a function that takes a whole object full of dependent functions, where the dependencies could be different for each key. So I tried two type parameters: one for the top-level keys, and one that should map from a top-level key to the dependency keys for that top-level key:

type DepFuncs<TopKeys extends string, SubKeys extends {[K in TopKeys]: string}> 
   = {[K in TopKeys]: DepFunc<SubKeys[K]>}

function bar<TopKeys extends string, SubKeys extends {[K in TopKeys]: string}>(
   par: DepFuncs<TopKeys, SubKeys>
) {
   return par
}

But unfortunately now it seems the type checking is lost. For example, in

const t = bar({func: n => n + 1, 
               another: {deps: {p: true}, build: dep => n => n + dep.p}})

if I change the dep.p to dep.q, TypeScript is perfectly happy, and looking at the whole thing in the Playground, it seems that TypeScript has chosen the SubKeys type parameter to map each of func and another to all of string, rather than narrowing SubKeys['another'] to just 'p' as it did in the case of foo.

Is there a different way I can parameterize DepFuncs and/or bar to get back that nice type narrowing for each of the properties of the argument to bar, that I observed in the single argument to foo?


Solution

  • This is essentially impossible in TypeScript (as of TypeScript 5.6). In order for something like this to work, TypeScript would need to be able to perform contextual and other generic inference over mapped types, to get "the same" behavior for each property of the input to bar that you get in foo. But this isn't implemented, and would need something like microsoft/TypeScript#53018 to be addressed first. Even then it's not obvious that it would behave as desired.

    You should either give up and manually annotate everything, or make inference easier on TypeScript by breaking up your single multi-prop object into multiple single-prop things, possibly by introducing a builder:

    const t = depFuncsBuilder
       .and("func", n => n + 1)
       .and("another", { deps: { p: true }, build: dep => n => n + dep.p })
       .build();
    //    ^? const t: { func: DepFunc<string>; another: DepFunc<"p">; }
    

    where, for example:

    const depFuncsBuilder = (() => {
       function b<T extends Record<keyof T, string>>(
          prev: { [K in string & keyof T]: DepFunc<T[K]> }
       ): DepFuncsBuilder<T> {
          return {
             and(k, f) {
                return b({ ...prev, [k]: f } as any)
             },
             build() {
                return prev;
             }
          }
       }
       return b({})
    })()
    
    interface DepFuncsBuilder<T extends Record<keyof T, string>> {
       and<K extends string, V extends string>(
         k: K, f: DepFunc<V>
       ): DepFuncsBuilder<T & Record<K, V>>;
       build(): { [K in string & keyof T]: DepFunc<T[K]> }
    }
    

    Here you are letting TypeScript do the successful inference by having and() behave like foo() and accept just a single DepFunc (and a corresponding key). Then when you're done you call build() and get the underlying object, of the correct type.

    Playground link to code