2

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?

cefn
  • 2,895
  • 19
  • 28
  • You want the type parameter utterances in `keys` to be *non-inferential* (or at least not to take precedence over those in `flags`), as described in [ms/TS#14829](https://github.com/microsoft/TypeScript/issues/14829). There isn't an official way to do that but there are various ways to get that result, such as adding a second type parameter as shown [in this playground link](https://tsplay.dev/W4jd7N). Does that meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz Feb 27 '23 at 13:55
  • Thanks, @jcalz I've used the two parameter trick at a cost - autocompletion stops working and defeats the purpose of having the generic inference for my motivating case. See https://github.com/microsoft/TypeScript/issues/52726 A valid `Key` type in scope from a `Flags` store should drive autocompletion of keys while editing calls which reference the store. Two other things tho: 1) Typescript's inference planning seems less than optimal here given there IS an inferential solution discarded. 2) The error `'boolean' is not assignable to type 'Flags[keyof Flags]'` looks a lot like a bug. – cefn Feb 27 '23 at 15:22
  • To be specific, this is the case that seems like a bug... https://tsplay.dev/N55ddN – cefn Feb 27 '23 at 15:31
  • Okay, how about [this version](https://tsplay.dev/wQ2BjN) where we use an inference-blocker? About your two other things; I worry we'll digress into rabbit holes; keeping that in mind, 1: heuristics that are suboptimal for one case might be nearly optimal for many more, so that's why they are chosen that way; 2: it's not a bug, `Flags` might be the type `{foo: false}` and you can't assign `true` to its property. – jcalz Feb 27 '23 at 15:33
  • That works! The inference-blocker approach is really powerful and makes it so worthwhile to have pursued this! This will be critical for me in the future, and looks from andarist's PR that it's a candidate for core support. It's also really useful to know which of the formulas suggested in the issue tracker you consider to be best for `NoInfer` in the meantime. Regards 1: presumably if there IS an inferential solution, then it should be adopted, right? Thanks for the clarification of 2: how narrowing could prevent assignment. I had only thought of narrowing the keys, not also the values. – cefn Feb 27 '23 at 16:22
  • I will write up an answer when I get a chance. (As for 1: it is almost always possible to make inference succeed but then you get the combination-of-all-inference-candidates and often people don't want to see that and would rather get an error. I *really* don't want to go further into a side discussion about this here. Is that okay?) – jcalz Feb 27 '23 at 17:16

1 Answers1

1

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • This is an incredibly valuable strategy and taught me about a clever mechanism that I'm sure I'll use again in the future. I've never been disappointed learning from @jcalz ideas. – cefn Feb 27 '23 at 23:05