Search code examples
typescriptgenericspropertiesrecordkeyof

Inferring Generic keys when editing Record


This code example is a function for terse immutable editing of a record to set boolean values.

The function should accept a record of boolean values and a list of matching keys. It should return a new record which has all those keys set to true (using an 'immutable' update pattern in which the original record is not modified).

The errors I'm getting are so fundamental that I feel I need another pair of eyes. I must be missing something. How can I configure Generics to get the code below to compile and run sensibly?

function createNextFlags<Key extends string>(
  flags: Record<Key, boolean>,
  ...keys: [Key, ...Key[]]
) {
  const nextFlags = {
    ...flags
  }
  for (const key of keys) {
    nextFlags[key] = true;
  }
  return nextFlags;
}

createNextFlags({
  vanilla:false,
  chocolate:true, // this line generates a compiler error because flags constraint is too narrow
}, "vanilla")

You can play with the problem at this playground

The errored line suggests there's a difficulty with Typescript inferring Key too narrowly. By inferring only from the keys array, rather than attempting to infer also from the flags object, it ends up complaining that the flags object is invalid if it has any property names which are not in keys...

MOTIVATING EXAMPLE

Although this is a very simple example, the much more complex example I am working on has a similar nature, with an error condition similar to that of the excess property checking here - in other words keys drives the constraining type of flags when it should be inferred the other way round - keys should be inferred from the properties of flags.

WORKAROUNDS

You would think the workaround below would create a placeholder for the type of the flags object....

// Define Flags explicitly

function createNextFlags<Flags extends Record<Key, boolean>, Key extends string>(
  flags: Flags,
  ...keys: [Key, ...Key[]]
) {
  const nextFlags = {
    ...flags
  }
  for (const key of keys) {
    nextFlags[key] = true; // this assignment is apparently illegal!
  }
  return nextFlags;
}

createNextFlags({
  vanilla:false,
  chocolate:true,
}, "vanilla")

However, the approach creates an even weirder error. Hovering over the apparently errored assignment to the nextFlags property shows the surprising error lines (I kid you not)...

const nextFlags: Flags extends Record<Key, boolean>
Type 'boolean' is not assignable to type 'Flags[Key]'

I have also tried using keyof to derive the keys directly from the type of flags like this with identical results, even though it fully eliminates the Key generic, and makes the type of keys fully derived from flags.

// use keyof to ensure that the property name aligns

function createNextFlags<Flags extends Record<any, boolean>>(
  flags: Flags,
  ...keys: [keyof Flags, ...(keyof Flags)[]]
)

However, it has just the same kind of error

const nextFlags: Flags extends Record<any, boolean>
Type 'boolean' is not assignable to type 'Flags[keyof Flags]'

I have also tried using infer to derive the keys directly from the type of flags like this...

type InferKey<Flags extends Record<any, boolean>> = Flags extends Record<infer Key, boolean> ? Key: never;

function createNextFlags<Flags extends Record<any, boolean>>(
  flags: Flags,
  ...keys: [InferKey<Flags>, ...InferKey<Flags>[]]
)

This leads to a similarly surprising error...

const nextFlags: Flags extends Record<any, boolean>
Type 'boolean' is not assignable to type 'Flags[InferKey<Flags>]'

What's the right way to approach this problem, so that the Key type can be inferred from the flags object, to constrain the keys argument? What am I missing?


Solution

  • When you call a generic function, TypeScript uses various heuristics to determine how to infer its type arguments. In general there are inference sites the compiler might consult to generate candidates for the generic type argument (via some heuristics), and then in the face of multiple candidates, it needs to figure out how to choose from them or a combination of them (via other heuristics). These heuristics work fairly well over a wide range of situations, but of course there are cases where the compiler does things the function designer doesn't want.

    For the function with the call signature

    function createNextFlags<K extends string>(
      flags: Record<K, boolean>,
      ...keys: [K, ...K[]]
    ): Record<K, boolean>;
    

    the heuristics as currently implemented give priority to the inference sites in keys and not the one in flags, and so you get the problem behavior described in the question.


    It would be nice if you as the function designer could tell the compiler "please don't try to infer K from keys. Infer it from flags and then just check it against the value passed in for keys". You'd be saying that the K utterances in keys are non-inferential type parameter usages. This is the subject of microsoft/TypeScript#14829, a request for some NoInfer<T> utility type (presumably an intrinsic one, as described in microsoft/TypeScript#40580) that evaluates to T but blocks inference.

    If that existed, you'd write

    declare function createNextFlags<K extends string>(
      flags: Record<K, boolean>,
      ...keys: [NoInfer<K>, ...NoInfer<K>[]]
    ): Record<K, boolean>;
    

    and things would start working the way you want.

    Unfortunately there is currently no such built-in utility type that works this way. Maybe one will be introduced in a TypeScript release someday, but for now it's not there.

    Luckily there are various user-level implementations that work for at least some use cases. One is shown here, where we use the compiler's tendency to defer evaluation of generic conditional types to our advantage:

    type NoInfer<T> = [T][T extends any ? 0 : never];
    

    And now we can try it:

    createNextFlags({
      vanilla: false,
      chocolate: true,
    }, "vanilla"); // okay
    
    createNextFlags({
      dog: true,
      cat: false
    }, "cat", "dog"); // okay
    
    createNextFlags({
      red: true,
      green: false,
      blue: true
    }, "green", "purple", "blue"); // error!
    // -------> ~~~~~~~~ // okay
    

    Looks good! The compiler infers K from the flags argument and checks against the subsequent arguments, as desired. The above definition of NoInfer<T> doesn't necessarily work in all use cases (the GitHub issue describes some failures) so it's not a panacea. But if it works for your purposes then it might be good enough.

    Playground link to code