¿Cómo verifico que un bloque de interruptores sea exhaustivo en TypeScript?

97

Tengo un código:

enum Color {
    Red,
    Green,
    Blue
}

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
        // Forgot about Blue
    }

    throw new Error('Did not expect to be here');
}

Olvidé manejar el Color.Bluecaso y preferiría haber recibido un error de compilación. ¿Cómo puedo estructurar mi código de modo que TypeScript marque esto como un error?

Ryan Cavanaugh
fuente
1
Solo quería informar a la gente que hay una solución de dos líneas de @Carlos Gines si se desplaza hacia abajo lo suficiente.
Noumenon

Respuestas:

122

Para hacer esto, usaremos el nevertipo (introducido en TypeScript 2.0) que representa valores que "no deberían" ocurrir.

El primer paso es escribir una función:

function assertUnreachable(x: never): never {
    throw new Error("Didn't expect to get here");
}

Luego úselo en el defaultcaso (o de manera equivalente, fuera del interruptor):

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
    }
    return assertUnreachable(c);
}

En este punto, verá un error:

return assertUnreachable(c);
       ~~~~~~~~~~~~~~~~~~~~~
       Type "Color.Blue" is not assignable to type "never"

¡El mensaje de error indica los casos que olvidó incluir en su exhaustivo cambio! Si deja varios valores, verá un error sobre, por ejemplo Color.Blue | Color.Yellow.

Tenga en cuenta que si lo está utilizando strictNullChecks, lo necesitará antes returnde la assertUnreachablellamada (de lo contrario, es opcional).

Puedes ponerte un poco más elegante si quieres. Si está utilizando una unión discriminada, por ejemplo, puede ser útil recuperar la propiedad discriminante en la función de aserción para fines de depuración. Se parece a esto:

// Discriminated union using string literals
interface Dog {
    species: "canine";
    woof: string;
}
interface Cat {
    species: "feline";
    meow: string;
}
interface Fish {
    species: "pisces";
    meow: string;
}
type Pet = Dog | Cat | Fish;

// Externally-visible signature
function throwBadPet(p: never): never;
// Implementation signature
function throwBadPet(p: Pet) {
    throw new Error('Unknown pet kind: ' + p.species);
}

function meetPet(p: Pet) {
    switch(p.species) {
        case "canine":
            console.log("Who's a good boy? " + p.woof);
            break;
        case "feline":
            console.log("Pretty kitty: " + p.meow);
            break;
        default:
            // Argument of type 'Fish' not assignable to 'never'
            throwBadPet(p);
    }
}

Este es un patrón agradable porque obtienes seguridad en tiempo de compilación para asegurarte de manejar todos los casos que esperabas. Y si obtiene una propiedad verdaderamente fuera de alcance (por ejemplo, algún llamador JS creó una nueva species), puede lanzar un mensaje de error útil.

Ryan Cavanaugh
fuente
3
Con strictNullCheckshabilitado, ¿no es suficiente definir el tipo de retorno de la función como string, sin necesidad de una assertUnreachablefunción en absoluto?
dbandstra
1
@dbandstra Ciertamente puede hacer eso, pero como patrón genérico, assertUnreachable es más confiable. Funciona incluso sin él strictNullChecksy también sigue funcionando si hay condiciones en las que desea volver indefinido desde fuera del conmutador.
Letharion
Esto no parece funcionar cuando la enumeración se define en un archivo d.ts externo. Al menos no puedo hacer que funcione con la enumeración Office.MailboxEnums.RecipientType de la API del complemento Microsoft Office JS.
Søren Boisen
la solución anterior funciona para el ejemplo básico en la descripción original, sin embargo, no estoy seguro de que sea un patrón de aplicación general. Cambie la enumeración de color con type InDiscriminant = { a: string } | { b: string }y ¿cómo se puede proceder? Un simple interruptor ya no conoce todos los valores fibrosos, y no hay propiedades comunes entre los tipos de miembros de la unión para discriminar. ¿Existe una solución idiomática para este caso?
Cdaringe el
noUnusedParametersLa configuración de tsconfig arrojará un "x nunca se usa error" que puede corregir function assertUnreachable(_x: never): never { throw new Error("Didn't expect to get here"); }
colocando un
33

No necesita usar neverni agregar nada al final de su switch.

Si

  • Su switchestado de cuenta regresa en cada caso
  • Tienes strictNullChecksactivado el indicador de compilación de mecanografiado
  • Tu función tiene un tipo de retorno especificado
  • El tipo de retorno no es undefinedovoid

Obtendrá un error si su switchdeclaración no es exhaustiva, ya que habrá un caso en el que no se devolverá nada.

De tu ejemplo, si lo haces

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
        // Forgot about Blue
    }
}

Obtendrá el siguiente error de compilación:

La función carece de declaración de retorno final y el tipo de retorno no se incluye undefined.

Marcelo Lazaroni
fuente
Esta solución requiere especificar explícitamente todos los posibles valores de retorno de una función. En otros casos, podemos omitirlos y dejar que el compilador haga inferencia de tipos.
Aleksei
sí, el compilador se quejará, pero también debe considerar que aún podría obtener un valor incorrecto en tiempo de ejecución. En tal caso, prefiero lanzar un mensaje de error significativo (en lugar de devolver implícitamente indefinido)
TmTron
6
La idea no es regresar undefined, sino crear la rama faltante del enunciado del caso. De esta manera, no obtendría un error en tiempo de ejecución y no es necesario lanzar nada.
Marcelo Lazaroni
3
El código funciona exactamente como se describe. Si ignora el tipo y considera que cpodría ser cualquier cosa, no hay razón para usar TypeScript en absoluto. Si cpudiera ser nulo o indefinido, el tipo debería decir eso y la implementación lo tendría en cuenta.
Marcelo Lazaroni
1
Esta solución funciona para desencadenar un error de mecanografía, pero el error no siempre es claro. Prefiero usar un defaultcaso con una variable no utilizada escrita como nevercon un comentario que explique "hey bud, if you're getting an error here, you forgot to add a case". YMMV.
Matthias
20

Solución

Lo que hago es definir una clase de error:

export class UnreachableCaseError extends Error {
  constructor(val: never) {
    super(`Unreachable case: ${JSON.stringify(val)}`);
  }
}

y luego lanza este error en el caso predeterminado:

enum Color {
    Red,
    Green,
    Blue
}

function getColorName(c: Color): string {
  switch(c) {
      case Color.Red:
          return 'red, red wine';
      case Color.Green:
          return 'greenday';
      case Color.Blue:
          return "Im blue, daba dee daba";
      default:
          // Argument of type 'c' not assignable to 'never'
          throw new UnreachableCaseError(c);
  }
}

Creo que es más fácil de leer que el enfoque de función recomendado por Ryan, porque la throwcláusula tiene el resaltado de sintaxis predeterminado.

Insinuación

La biblioteca ts-essentials tiene una clase UnreachableCaseError exactamente para este caso de uso

Consideraciones de tiempo de ejecución

Tenga en cuenta que el código mecanografiado se transpila a javascript: por lo tanto, todas las comprobaciones mecanografiadas sólo funcionan en tiempo de compilación y no existen en tiempo de ejecución: es decir, no hay garantía de que la variable csea ​​realmente de tipo Color.
Esto es diferente de otros lenguajes: por ejemplo, Java también verificará los tipos en tiempo de ejecución y arrojará un error significativo si intenta llamar a la función con un argumento de tipo incorrecto, pero javascript no lo hace.

Esta es la razón por la que es importante lanzar una excepción significativa en el defaultcaso: Stackblitz: arrojar un error significativo

Si no hiciste esto, la función getColorName()regresaría implícitamente undefined(cuando se llama con un argumento inesperado): Stackblitz: return any

En los ejemplos anteriores, usamos directamente una variable de tipo anypara ilustrar el problema. Es de esperar que esto no suceda en proyectos del mundo real, pero hay muchas otras formas de obtener una variable de un tipo incorrecto en tiempo de ejecución.
Aquí hay algunos que ya he visto (y yo mismo cometí algunos de estos errores):

  • usando formas angulares - estas no son seguras de tipos: todos los valores de campo de formulario son de tipo any
    ng-forms Stackblitz ejemplo
  • implícito cualquiera está permitido
  • se utiliza un valor externo y no se valida (por ejemplo, la respuesta http del servidor simplemente se envía a una interfaz)
  • leemos un valor del almacenamiento local que ha escrito una versión anterior de la aplicación (estos valores han cambiado, por lo que la nueva lógica no comprende el valor anterior)
  • usamos algunas bibliotecas de terceros que no son seguras de tipos (o simplemente tienen un error)

Así que no sea perezoso y escriba este caso adicional predeterminado: puede evitarle muchos dolores de cabeza ...

TmTron
fuente
tenga en cuenta que actualmente no puede usar instanceofcomprobaciones para subclases de Error: consulte el número 13965 que también menciona una solución alternativa
TmTron
Lo comprobé y ahora funciona correctamente. TypeScript 3.5.1.
Aleksei
1
Ooooh, esto es hermoso, y también casi exactamente cómo escribiría el código, si estuviera escribiendo en JS simple.
Pete
¿Cómo encuentra esto el compilador?
jocull
@jocull no estoy seguro de lo que está pidiendo. ¿Quizás quieras leer sobre sindicatos discriminados ?
TmTron
18

Sobre la base de la respuesta de Ryan, descubrí aquí que no hay necesidad de ninguna función adicional. Podemos hacer directamente:

function getColorName(c: Color): string {
  switch (c) {
    case Color.Red:
      return "red";
    case Color.Green:
      return "green";
    // Forgot about Blue
    default:
      const _exhaustiveCheck: never = c;
      throw new Error("How did we get here?");
  }
}

Puedes verlo en acción aquí en TS Playground

Carlos Ginés
fuente
2
También prefiero usar variables, en lugar de crear una función (aunque ambas pueden eliminarse en el momento del paquete). En cualquier caso, su linter puede quejarse de una variable no utilizada, y tendrá que agregar algo como esto encima:// eslint-disable-next-line @typescript-eslint/no-unused-vars
Matthias
2
O puede usar la variable en el error que arroja, algo como:throw new Error(`Unexpected: ${_exhaustiveCheck}`);
Nooodles
1
Buena idea. También puede definir una función anónima:default: ((x: never) => { throw new Error(c + " was unhandled."); })(c);
Brady Holt
Puedo confirmar que la solución @Nooodles satisface la verificación de variables no utilizadas de SonarQube. Gracias. Me encanta lo conciso que es esto.
Noumenon
Abordé algunos errores relacionados (creo que estos podrían ser bastante frecuentes) del código anterior como este:default: { const pleaseBeExhaustive: never = c; throw new Error(`Use all Color - ${pleaseBeExhaustive as string}`); }
Jonny
5

Como un buen giro en la respuesta de Ryan, puede reemplazarlo nevercon una cadena arbitraria para que el mensaje de error sea más fácil de usar.

function assertUnreachable(x: 'error: Did you forget to handle this type?'): never {
    throw new Error("Didn't expect to get here");
}

Ahora obtienes:

return assertUnreachable(c);
       ~~~~~~~~~~~~~~~~~~~~~
       Type "Color.Blue" is not assignable to type "error: Did you forget to handle this type?"

Esto funciona porque neverse puede asignar a cualquier cosa, incluida una cadena arbitraria.

Hadrien Milano
fuente
3

Sobre la base de las respuestas de Ryan y Carlos , puede usar un método anónimo para evitar tener que crear una función con nombre separada:

function getColorName(c: Color): string {
  switch (c) {
    case Color.Red:
      return "red";
    case Color.Green:
      return "green";
    // Forgot about Blue
    default:
      ((x: never) => {
        throw new Error(`${x} was unhandled!`);
      })(c);
  }
}

Si su cambio no es exhaustivo, obtendrá un error de tiempo de compilación .

Brady Holt
fuente
2

Para evitar las advertencias de Typecript o linter:

    default:
        ((_: never): void => {})(c);

en contexto:

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
        default:
            ((_: never): void => {})(c);
    }
}

La diferencia entre esta solución y las demás es

  • no hay variables nombradas sin referencia
  • no lanza una excepción ya que Typecript hará cumplir que el código se neverejecutará de todos modos
Chris G
fuente
Explique qué hace el código en detalle y en qué se diferencia de otras respuestas
Sujitmohanty30
1

En casos realmente simples, cuando solo necesita devolver una cadena por valor de enumeración, es más fácil (en mi humilde opinión) usar alguna constante para almacenar el diccionario de resultados en lugar de usar el interruptor. Por ejemplo:

enum Color {
    Red,
    Green,
    Blue
}

function getColorName(c: Color): string {
  const colorNames: Record<Color, string> = {
    [Color.Red]: `I'm red`,
    [Color.Green]: `I'm green`,
    [Color.Blue]: `I'm blue, dabudi dabudai`,   
  }

  return colorNames[c] || ''
}

Entonces, aquí tendrá que mencionar cada valor de enumeración en constante, de lo contrario obtendrá un error como, por ejemplo, si falta Blue:

TS2741: Falta la propiedad 'Blue' en el tipo '{[Color.Red]: string; [Color.Verde]: cadena; ' pero se requiere en el tipo 'Registro'.

Sin embargo, a menudo no es el caso y entonces es realmente mejor lanzar un error como propuso Ryan Cavanaugh.

También me molestó un poco cuando descubrí que esto no funcionaría también:

function getColorName(c: Color): string {
    switch(c) {
        case Color.Red:
            return 'red';
        case Color.Green:
            return 'green';
    }
    return '' as never // I had some hope that it rises a type error, but it doesn't :)
}
Alendorff
fuente
-1

Cree una función personalizada en lugar de utilizar una switchdeclaración.

export function exhaustSwitch<T extends string, TRet>(
  value: T,
  map: { [x in T]: () => TRet }
): TRet {
  return map[value]();
}

Uso de ejemplo

type MyEnum = 'a' | 'b' | 'c';

const v = 'a' as MyEnum;

exhaustSwitch(v, {
  a: () => 1,
  b: () => 1,
  c: () => 1,
});

Si luego agrega da MyEnum, recibirá un errorProperty 'd' is missing in type ...

cielo
fuente
Esto solo funciona si sus valores de enumeración son cadenas, ya que deben codificarse como claves en el parámetro del objeto map.
Will Madden