Búsqueda de sitios web

Typescript no infiere subtipos a partir de Array.includes


Estoy tratando de que TypeScript infiera correctamente algunas variables usando Array.includes(), pero no funciona. Aquí hay un ejemplo fácil de entender:

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

Ahora digamos que tengo una función que recibe una clave de SomeType, pero en realidad solo quiero hacer cosas cuando mi clave está dentro de mi Array

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

Aquí, el tipo de campo dado a la función doStuff debería ser typeof Array, pero en realidad es keyof SomeType

Supongo que este comportamiento se debe a algunos casos límite oscuros, pero me gustaría encontrar una solución para esto o al menos entender el problema subyacente del código anterior y por qué Typescript cree que esto es un error

He encontrado algunos problemas relacionados en GH, pero realmente no pude entenderlo todo :

  • Número 1
  • Número 2

Respuesta #1

Actualmente, las definiciones de tipos de TypeScript para el método de arrays includes() no permiten que actúe como guardia de tipos sobre su entrada. Esto se sugirió en microsoft/TypeScript#36275, pero se rechazó por ser demasiado complejo de implementar. La razón por la que no se permite tiene que ver con lo que implica cuando includes() devuelve false. Es fácil razonar sobre el resultado true; si arr.includes(x) es true, entonces, por supuesto, x tiene que ser del mismo tipo que los elementos de arr. Pero si arr.includes(x) es false, entonces, a menudo resulta un error decir que el tipo de x no es el mismo que el tipo de los elementos de arr. Por ejemplo, considera lo que ocurriría aquí:

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

El valor x es de tipo string o number. Si [1, 2, 3].includes(x) es true, entonces sabemos que x es un number. Pero si es false, no podemos concluir absolutamente que x es un string. Pero eso es lo que sucedería si includes() fuera una función de protección de tipo. No hay soporte incorporado para decir "oh, solo ignora el caso negativo". Eso requeriría algo como funciones de protección de tipo "unilaterales" o "de grano fino", como se solicita en microsoft/TypeScript#15048, uno de los problemas que mencionaste.

Este cambio sería inmediatamente notable y desagradable, ya que las llamadas a includes() en todo el mundo que utiliza TypeScript de repente verían efectos muy extraños donde sus valores buscados se reducirían incorrectamente, posiblemente hasta el tipo never.


Entonces, el enfoque ingenuo para convertirlo en un guardia de tipo sería un problema. Podríamos intentar ser más sofisticados y permitir que la función actúe como un guardia de tipo solo en circunstancias como tu código, donde el tipo de elemento del array es una unión de tipos literales. Pero esto empieza a parecer más complicado y extraño, involucrando tipos condicionales y parámetros this, tal vez así:

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

Eso intenta detectar si T es un tipo literal de cadena, pero no string en sí, y luego hace que la función actúe como un protector de tipos. Pero incluso eso no es seguro, ya que nada requiere que un arreglo contenga cada elemento de su tipo:

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
   }
}

Eso podría no ser probable en la práctica, pero es una complicación. Incluso si no nos importara eso, agregar nuevas firmas de llamada extrañas a funciones disponibles globalmente puede tener efectos notorios en el código de otras personas. (La inferencia hace cosas extrañas con funciones sobrecargadas, véase ms/TS#26591 entre muchos otros). Simplemente no vale la pena el esfuerzo y el riesgo para todo TypeScript solo para soportar este único caso de uso.


Si solo vas a hacer esto una vez, te recomendaría que simplemente afirmes y sigas adelante:

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

De lo contrario, podrías envolverlo en tu propia función de protección de tipo personalizada y usarla cuando necesites este comportamiento:

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"
   }
}

O, si realmente quieres ver este comportamiento en todas partes de tu base de código, podrías fusionar esa firma de llamada en la interfaz Array<T>; así sería si microsoft/TypeScript#36275 hubiera sido aceptado, pero no afecta a nadie más.

// 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"
   }
}

Enlace de Playground al código

Artículos relacionados