1

I'm trying to get TypeScript to correctly infer some variables using Array.includes() but it doesn't work. Here's an easy to get example :

const Array = ['a', 'b', 'c'] as const
type SomeType = {
  a: string
  b: string
  c: string
  foo: boolean
  bar: boolean
}

Now let's say I'm having a function that receive a key of SomeType but I actually only want to do stuff when my key is within my Array

const myAwesomeFunction = (field: keyof SomeType): void => {
  if (Array.includes(field) doStuff(field) 
}

Here the type of field given to the doStuff function should be typeof Array but it actually is keyof SomeType

I guess this behavior is due to some obscure edge cases but I would like to find a work around for this or at least understand the underlying issue of the code above and why does Typescript think this is a mistake

I've found some issues related on GH but I couldn't really understand it all :

  • 1
    TypeScript doesn't know that the return type of `includes()` should imply anything about the *type* of its input. You could make it a type guard function, but naive implementations of that would do incorrect things in some common (i.e., not obscure) cases, and more sophisticated implementations are quite complex and could have other unpleasant effects on all code. If you want such an implementation in your code base you could merge one in, as shown [in this playground link](//tsplay.dev/mAxE4N). If this meets your needs I'll write up a full answer explaining; otherwise, what am I missing? – jcalz Jun 05 '23 at 14:54
  • `Here the type of field given to the doStuff function should be typeof Array` Why do you say that? – Daniel Kaplan Jun 05 '23 at 18:00
  • @jcalz Indeed your playground is the answer to my problem though I really don't understand how it's working If you have spare time to write the explanations I'd be glad to read it all – Guillaume Roche-Bayard Jun 06 '23 at 07:47
  • @DanielKaplan as shown in the playground above, since we're filtering using an array, field is now for sure one of the values of the Array – Guillaume Roche-Bayard Jun 06 '23 at 07:47

1 Answers1

1

Currently the TypeScript typings for the includes() array method don't allow it to act as type guard on its input. This was suggested at microsoft/TypeScript#36275, but declined as too complex to implement. The reason why it isn't allowed has to do with what it implies when includes() returns false. It's easy to reason about the true result; if arr.includes(x) is true, then of course x has to be the same type as the elements of arr. But if arr.includes(x) is false, then it is quite often a mistake to say that the type of x is not the type of the elements of arr. For example, consider what would happen here:

function hmm(x: string | number) {
   if (!([1, 2, 3].includes(x))) {
      x.toUpperCase();
   }
}

The value x is of either a string or a number type. If [1, 2, 3].includes(x) is true, then we know x is a number. But if it is false, we absolutely cannot conclude that x is a string. But that's what would happen if includes() were a type guard function. There is no built-in support to say "oh just ignore the negative case". That would require something like "one-sided" or "fine-grained" type guard functions, as requested in microsoft/TypeScript#15048, one of the issues you mentioned.

This change would be immediately noticeable and unpleasant, as includes() calls throughout the TypeScript-using world would suddenly see very strange effects where their searched values got narrowed incorrectly, possibly all the way to the never type.


So the naive approach to making it a type guard would be a problem. We could try to be more sophisticated and only allow the function to act as a type guard in circumstances like your code, where the array element type is a union of literal types. But this starts to look more complicated and bizarre, involving conditional types and this parameters, maybe like this:

interface ReadonlyArray<T> {
   includes(
      this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
      searchElement: string
   ): searchElement is string & T;
}

That tries to detect if T is a string literal type, but not string itself, and then it makes the function act as a type guard. But even that is not safe, since nothing requires an array to contain every element of its type:

function yuck(x: "a" | "c") {
   const arr: readonly ("a" | "b")[] = ["b"]; // <-- doesn't contain "a"
   if (!(arr.includes(x))) {
      x; // x has been erroneously narrowed to "c"
      ({ c: 123 })[x].toFixed(1); // runtime error
   }
}

That might not be likely in practice, but it's a complication. Even if we didn't care about that, adding new weird call signatures to globally available functions can have noticeable effects on other people's code. (Inference does weird things with overloaded functions, see ms/TS#26591 among countless others). It's just not worth the effort and risk to all of TypeScript just to support this one use case.


If you're only doing this once, I'd recommend you just assert and move on:

const myAwesomeFunction = (field: keyof SomeType): void => {
   if (array.includes(field)) {
      doStuff(field as "a" | "b" | "c")  // assert
   } else {
      field as "foo" | "bar" // assert
   }
}

Otherwise you could wrap it in your own custom type guard function and use it when you need this behavior:

function myIncludes<T extends U, U>(
  arr: readonly T[], searchElement: U
): searchElement is T {
   return (arr as readonly any[]).includes(searchElement);
}

const myAwesomeFunction = (field: keyof SomeType): void => {
   if (myIncludes(array, field)) {
      doStuff(field)
   } else {
      field // (parameter) field: "foo" | "bar"
   }
}

Or, if you really want to see this behavior everywhere in your code base, you could merge that call signature into the Array<T> interface; this is what it would be like if microsoft/TypeScript#36275 had been accepted, but it doesn't affect anyone else.

// declare global { // uncomment this if you're in a module
interface ReadonlyArray<T> {
   includes(
      this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
      searchElement: string
   ): searchElement is string & T;
}
// } // uncomment this if you're in a module


const myAwesomeFunction = (field: keyof SomeType): void => {
   if (array.includes(field)) {
      doStuff(field)
   } else {
      field // (parameter) field: "foo" | "bar"
   }
}

Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360