Post Top Ad

Your Ad Spot

domingo, 28 de junio de 2020

Alternativas a las enumeraciones en TypeScript



Una publicación reciente del blog exploró cómo funcionan las enumeraciones de TypeScript. En esta publicación de blog, analizamos alternativas a las enumeraciones.

Tabla de contenido:

Uniones de valores singleton   

Una alternativa a la creación de una enumeración que asigna claves a valores es crear una unión de tipos singleton (uno por valor). Sigue leyendo para ver cómo funciona.

Uniones de tipos literales de cadena   

Comencemos con una enumeración y conviértala en una unión de tipos literales de cadena.
enum NoYesEnum {
  No = 'No',
  Yes = 'Yes',
}
function toGerman(value: NoYesEnum): string {
  switch (value) {
    case NoYesEnum.No:
      return 'Nein';
    case NoYesEnum.Yes:
      return 'Ja';
  }
}
assert.equal(toGerman(NoYesEnum.No), 'Nein');
assert.equal(toGerman(NoYesEnum.Yes), 'Ja');
Una alternativa es usar una unión tipo:
type NoYesStrings = 'No' | 'Yes';
Resumen rápido :
  • Podemos considerar los tipos como conjuntos de valores.
  • Un tipo singleton es un tipo con un elemento.
  • El operador de tipo de unión |está relacionado con el operador de unión teórico de conjuntos .
En este caso, los operandos de la unión de tipo son tipos literales de cadena :
  • 'No' es el tipo cuyo único elemento es la cadena 'No'
  • 'Yes' es el tipo cuyo único elemento es la cadena 'Yes'
Estos tipos se especifican con la misma sintaxis que los literales de cadena, pero existen en un nivel diferente:
  • Tipo literal de cadena: nivel de tipo: representa un conjunto con una sola cadena
  • Cadena literal - nivel de valor: representa una cadena
Usemos NoYesStringsen un ejemplo:
function toGerman(value: NoYesStrings): string {
  switch (value) {
    case 'No':
      return 'Nein';
    case 'Yes':
      return 'Ja';
  }
}
assert.equal(toGerman('No'), 'Nein');
assert.equal(toGerman('Yes'), 'Ja');

Las uniones de tipos literales de cadena se verifican por exhaustividad   

El siguiente código demuestra que TypeScript comprueba la exhaustividad de las uniones de tipos literales de cadena:
// @ts-ignore Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman2(value: NoYesStrings): string {
  switch (value) {
    case 'Yes':
      return 'Ja';
  }
}
Olvidamos el caso 'No'y TypeScript nos advierte que ya no se garantiza que la función solo devuelva cadenas.

Desventaja: las uniones de literales de cadena son menos seguras para los tipos   

Una desventaja de las uniones literales de cadena es que los valores que no son miembros pueden considerarse erróneamente miembros:
type Spanish = 'no' | 'sí';
type English = 'no' | 'yes';

const spanishWord: Spanish = 'no';
const englishWord: English = spanishWord;
Esto es lógico porque el español 'no'y el inglés 'no'tienen el mismo valor. El verdadero problema es que no hay forma de darles identidades diferentes.

Uniones de símbolos singleton tipos   

En lugar de uniones de tipos literales de cadena, también podemos usar uniones de tipos singleton de símbolos. Comencemos con una enumeración diferente esta vez:
enum LogLevel {
  off = 'off',
  info = 'info',
  warn = 'warn',
  error = 'error',
}
Traducido a una unión de tipos de símbolos únicos, tiene el siguiente aspecto:
const off = Symbol('off');
const info = Symbol('info');
const warn = Symbol('warn');
const error = Symbol('error');
type LogLevel =
  | typeof off
  | typeof info
  | typeof warn
  | typeof error
;
La siguiente función traduce miembros de LogLevela cadenas:
function toString(logLevel: LogLevel): string {
  switch (logLevel) {
    case off:
      return 'off';
    case info:
      return 'info';
    case warn:
      return 'warn';
    case error:
      return 'error';
  }
}
Comparemos este enfoque con las uniones de tipos literales de cadena:
  • Las comprobaciones de agotamiento funcionan para ambos enfoques.
  • Usar símbolos es más detallado
  • A diferencia de los tipos literales de cadena, los símbolos son de tipo seguro.
El último punto se demuestra en el siguiente ejemplo:
const spanishNo = Symbol('no');
const spanishSí = Symbol('sí');
type Spanish = typeof spanishNo | typeof spanishSí;

const englishNo = Symbol('no');
const englishYes = Symbol('yes');
type English = typeof englishNo | typeof englishYes;

const spanishWord: Spanish = spanishNo;
// @ts-ignore: Type 'unique symbol' is not assignable to type 'English'. (2322)
const englishWord: English = spanishNo;

Escriba uniones contra enumeraciones   

Las uniones de tipo y las enumeraciones tienen algunas cosas en común:
  • Puede completar automáticamente los valores de los miembros. Sin embargo, lo haces de manera diferente:
    • Con las enumeraciones, se completa automáticamente después del nombre de la enumeración y un punto.
    • Con las uniones de tipo, se completa automáticamente o, si es una unión de tipos literales de cadena, dentro de comillas literales de cadena.
  • Los controles de agotamiento funcionan para ambos.
Pero también difieren. Los inconvenientes de las uniones de tipos de símbolos únicos son:
  • Son ligeramente verbosas.
  • No hay espacio de nombres para sus miembros.
  • Es un poco más difícil migrar de ellos a diferentes construcciones (si fuera necesario): es más fácil encontrar dónde se mencionan los valores de los miembros enum.
Los aspectos positivos de las uniones de tipos singleton de símbolos son:
  • No son una construcción de lenguaje TypeScript personalizada y, por lo tanto, están más cerca de JavaScript simple.
  • Las enumeraciones de cadena solo son de tipo seguro en tiempo de compilación. Las uniones de tipos singleton de símbolos también son de tipo seguro en tiempo de ejecución.
    • Esto es importante especialmente si nuestro código compilado de TypeScript interactúa con código JavaScript simple.

Sindicatos discriminados   

Las uniones discriminadas están relacionadas con los tipos de datos algebraicos en lenguajes de programación funcionales.
Para comprender cómo funcionan, considere el árbol de sintaxis de estructura de datos que representa expresiones como:
1 + 2 + 3
Un árbol de sintaxis es:
  • Un número
  • La adición de dos árboles de sintaxis
Comenzaremos creando una jerarquía de clases orientada a objetos. Luego lo transformaremos en algo un poco más funcional. Y finalmente, terminaremos con una unión discriminada.

Paso 1: el árbol de sintaxis como una jerarquía de clases   

Esta es una implementación típica de OOP de un árbol de sintaxis:
// Abstract = can’t be instantiated via `new`
abstract class SyntaxTree1 {}
class NumberValue1 extends SyntaxTree1 {
  constructor(public numberValue: number) {
    super();
  }
}
class Addition1 extends SyntaxTree1 {
  constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
    super();
  }
}
SyntaxTree1es la superclase de NumberValue1y Addition1. La palabra clave publices azúcar sintáctica para:
  • Declarando la propiedad de instancia .numberValue
  • Inicializando esta propiedad a través del parámetro numberValue
Este es un ejemplo de uso SyntaxTree1:
const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3), // trailing comma
  ), // trailing comma
);
Nota: Las comas finales en las listas de argumentos están permitidas en JavaScript desde ECMAScript 2016.

Paso 2: el árbol de sintaxis como una unión tipo de clases   

Si definimos el árbol de sintaxis a través de una unión de tipo (línea A), no necesitamos herencia orientada a objetos:
class NumberValue2 {
  constructor(public numberValue: number) {}
}
class Addition2 {
  constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {}
}
type SyntaxTree2 = NumberValue2 | Addition2; // (A)
Dado NumberValue2y Addition2no tienen una superclase, que no necesitan invocar super()en sus constructores.
Curiosamente, creamos árboles de la misma manera que antes:
const tree = new Addition2(
  new NumberValue2(1),
  new Addition2(
    new NumberValue2(2),
    new NumberValue2(3), // trailing comma
  ), // trailing comma
);

Paso 3: el árbol de sintaxis como una unión discriminada   

Finalmente, llegamos a sindicatos discriminados. Estas son las definiciones de tipo para SyntaxTree3:
interface NumberValue3 {
  type: 'number-value';
  numberValue: number;
}
interface Addition3 {
  type: 'addition';
  operand1: SyntaxTree3;
  operand2: SyntaxTree3;
}
type SyntaxTree3 = NumberValue3 | Addition3;
Hemos cambiado de clases a interfaces y, por lo tanto, de instancias de clases a objetos simples.
Las interfaces de una unión discriminada deben tener al menos una propiedad en común y esa propiedad debe tener un valor diferente para cada una de ellas. Esa propiedad se llama discriminante o etiqueta . Comparar:
  • La clase de una instancia generalmente está determinada por su cadena de prototipo.
  • El tipo de miembro de un sindicato discriminado está determinado por su discriminante.
Este es un miembro de SyntaxTree3:
const tree: SyntaxTree3 = { // (A)
  type: 'addition',
  operand1: {
    type: 'number-value',
    numberValue: 1,
  },
  operand2: {
    type: 'addition',
    operand1: {
      type: 'number-value',
      numberValue: 2,
    },
    operand2: {
      type: 'number-value',
      numberValue: 3,
    },
  }
};
No necesitamos la anotación de tipo en la línea A, pero ayuda a garantizar que todos los objetos tengan la estructura correcta. Si no lo hacemos aquí, descubriremos los problemas más adelante.
TypeScript rastrea el valor del discriminante y actualiza el tipo de miembro de la unión en consecuencia:
function getNumberValue(tree: SyntaxTree3) {
  // %inferred-type: SyntaxTree3
  tree; // (A)

  // @ts-ignore: Property 'numberValue' does not exist on type 'SyntaxTree3'.
  //   Property 'numberValue' does not exist on type 'Addition3'.(2339)
  tree.numberValue; // (B)

  if (tree.type === 'number-value') {
    // %inferred-type: NumberValue3
    tree; // (C)
    return tree.numberValue; // OK!
  }
  return null;
}
En la línea A, todavía no hemos verificado el discriminante .type. Por lo tanto, el tipo actual de treetodavía es SyntaxTree3y no podemos acceder a la propiedad .numberValueen la línea B.
En la línea C, TypeScript sabe que .typees 'number-value'y, por lo tanto, puede inferir el tipo NumberValue3para tree. Es por eso que acceder .numberValueen la siguiente línea está bien, esta vez.

Implementación de funciones para sindicatos discriminados   

Concluimos este paso con un ejemplo de cómo implementar funciones para sindicatos discriminados.
Si hay una operación que se puede aplicar a los miembros de todos los subtipos, los enfoques para las clases y los sindicatos discriminados difieren:
  • Con las clases, es común usar un método polimórfico donde cada clase tiene una implementación diferente.
  • Con uniones discriminadas, es común usar una sola función que maneja todos los casos posibles y decide qué hacer al examinar el discriminante de su parámetro.
El siguiente ejemplo demuestra el último enfoque. El discriminante se examina en la línea A y determina cuál de los dos switchcasos se ejecuta.
function syntaxTreeToString(tree: SyntaxTree3): string {
  switch (tree.type) { // (A)
    case 'addition':
      return syntaxTreeToString(tree.operand1)
        + ' + ' + syntaxTreeToString(tree.operand2);
    case 'number-value':
      return String(tree.numberValue);
  }
}

assert.equal(syntaxTreeToString(tree), '1 + 2 + 3');
Tenga en cuenta que TypeScript realiza una comprobación exhaustiva de las uniones discriminadas: si olvidamos un caso, TypeScript nos avisará.
abstract class SyntaxTree1 {
  // Abstract = enforce that all subclasses implement this method:
  abstract toString(): string;
}
class NumberValue1 extends SyntaxTree1 {
  constructor(public numberValue: number) {
    super();
  }
  toString(): string {
    return String(this.numberValue);
  }
}
class Addition1 extends SyntaxTree1 {
  constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) {
    super();
  }
  toString(): string {
    return this.operand1.toString() + ' + ' + this.operand2.toString();
  }
}

const tree = new Addition1(
  new NumberValue1(1),
  new Addition1(
    new NumberValue1(2),
    new NumberValue1(3),
  ),
);

assert.equal(tree.toString(), '1 + 2 + 3');
Tenga en cuenta que con el enfoque OOP, tuvimos que modificar las clases para agregar funcionalidad. En contraste, con el enfoque funcional, las partes externas pueden agregar funcionalidad.

Uniones tipo discriminadas versus uniones tipo normales   

Los sindicatos discriminados y los sindicatos de tipo normal tienen dos cosas en común:
  • No hay espacio de nombres para los valores de los miembros.
  • TypeScript realiza la comprobación de exhaustividad.
Las siguientes dos subsecciones exploran dos ventajas de los sindicatos discriminados sobre los sindicatos normales:

Beneficio: nombres de propiedad descriptivos   

Con uniones discriminadas, los valores obtienen nombres de propiedad descriptivos. Comparemos:
Unión normal:
type FileGenerator = (webPath: string) => string;
type FileSource1 = string|FileGenerator;
Unión discriminada
interface FileSourceFile {
  type: 'FileSourceFile',
  nativePath: string,
}
interface FileSourceGenerator {
  type: 'FileSourceGenerator',
  fileGenerator: FileGenerator,
}
type FileSource2 = FileSourceFile | FileSourceGenerator;
Ahora, las personas que leen el código fuente saben de inmediato qué es la cadena: un nombre de ruta nativo.

Beneficio: también se puede usar cuando las partes no se pueden distinguir   

La siguiente unión discriminada no se puede implementar como una unión normal porque no podemos distinguir los tipos de unión en TypeScript.
interface TemperatureCelsius {
  type: 'TemperatureCelsius',
  value: number,
}
interface TemperatureFahrenheit {
  type: 'TemperatureFahrenheit',
  value: number,
}
type Temperature = TemperatureCelsius | TemperatureFahrenheit;

Literales de objeto como enumeraciones   

El siguiente patrón para implementar enumeraciones es común en JavaScript:
const Color = {
  red: Symbol('red'),
  green: Symbol('green'),
  blue: Symbol('blue'),
};
Podemos intentar usarlo en TypeScript de la siguiente manera:
// %inferred-type: symbol
type TColor = (typeof Color)[keyof typeof Color]; // (A)

function toGerman(color: TColor): string {
  switch (color) {
    case Color.red:
      return 'rot';
    case Color.green:
      return 'grün';
    case Color.blue:
      return 'blau';
    default:
      // No exhaustiveness check (inferred type should be `never`):
      // %inferred-type: symbol
      color;

      // Prevent static error for return type:
      throw new Error();
  }
}
Por desgracia, en la línea A, TypeScript infiere el tipo symbol. En consecuencia, podemos pasar cualquier símbolo toGerman()y TypeScript no se quejará en el momento de la compilación:
assert.equal(
  toGerman(Color.green), 'grün');
assert.throws(
  () => toGerman(Symbol())); // no static error!
Podemos intentar definirlo de manera TColordiferente, pero eso no cambia nada:
// %inferred-type: symbol
type TColor2 =
  | typeof Color.red
  | typeof Color.green
  | typeof Color.blue
;
Por el contrario, si usamos constantes en lugar de propiedades, obtenemos una unión entre tres valores diferentes:
const red = Symbol('red');
const green = Symbol('green');
const blue = Symbol('blue');

// %inferred-type: unique symbol | unique symbol | unique symbol
type TColor2 = typeof red | typeof green | typeof blue;

Literales de objeto con propiedades con valores de cadena   

const Color = {
  red: 'red',
  green: 'green',
  blue: 'blue',
} as const; // (A)

// %inferred-type: "red" | "green" | "blue"
type TColor = (typeof Color)[keyof typeof Color];
Necesitamos as consten la línea A para que TColorno sea así string. Por desgracia, no cambia nada si los valores de las propiedades son símbolos.
El uso de propiedades con valores de cadena es:
  • Mejor en tiempo de desarrollo porque obtenemos verificaciones de exhaustividad y un tipo estático para los valores de enumeración.
  • Peor en tiempo de ejecución porque las cadenas pueden confundirse con valores de enumeración.

Ventajas y desventajas de usar literales de objeto como enumeraciones   

Upsides:
  • Tenemos un espacio de nombres para los valores.
  • No utilizamos una construcción personalizada y estamos más cerca de JavaScript simple.
  • Se realizan comprobaciones de exhaustividad (si utilizamos propiedades con valores de cadena).
  • Hay un tipo estrecho para valores de enumeración (si utilizamos propiedades con valores de cadena).
Desventajas:
  • No es posible la verificación de membresía dinámica (sin trabajo adicional).
  • Los valores que no son enum pueden confundirse con valores enum estáticos o en tiempo de ejecución (si usamos propiedades con valores de cadena).

Patrón de enumeración   

El siguiente ejemplo muestra un patrón de enumeración inspirado en Java que funciona en JavaScript simple y TypeScript:
class Color {
  static red = new Color();
  static green = new Color();
  static blue = new Color();
}

// @ts-ignore: Function lacks ending return statement and return type
// does not include 'undefined'. (2366)
function toGerman(color: Color): string { // (A)
  switch (color) {
    case Color.red:
      return 'rot';
    case Color.green:
      return 'grün';
    case Color.blue:
      return 'blau';
  }
}

assert.equal(toGerman(Color.blue), 'blau');
Por desgracia, TypeScript no realiza comprobaciones de exhaustividad, por lo que tenemos un error en la línea A.

Resumen de enumeraciones y alternativas de enumeración   

La siguiente tabla resume las características de las enumeraciones y sus alternativas en TypeScript:
ÚnicoNombresp.IterMem. ConnecticutMem. RTCansada.
Enumeraciones--
Enumeraciones de cuerdas-
Uniones de cuerda----
Uniones de símbolos---
Discriminar sindicatos- (1)--- (2)
Propiedades de símbolos---
Propiedades de cadena--
Patrón de enumeración-
Títulos de columnas de tabla:
  • Valores únicos: ningún valor que no sea enum puede confundirse con un valor enum.
  • Espacio de nombres para claves de enumeración
  • ¿Es posible iterar sobre valores de enumeración?
  • Verificación de membresía para valores en tiempo de compilación: ¿Hay un tipo estrecho para un conjunto de valores de enumeración?
  • Verificación de membresía para valores en tiempo de ejecución.
    • Para el patrón enum, la prueba de membresía en tiempo de ejecución es instanceof.
    • Tenga en cuenta que una prueba de membresía se puede implementar con relativa facilidad si es posible iterar sobre valores de enumeración.
  • Comprobación de exhaustividad (estáticamente por TypeScript)
Notas al pie en las celdas de la tabla:
  1. Los sindicatos discriminados no son realmente únicos, pero confundir los valores de los miembros del sindicato es relativamente poco probable (especialmente si usamos un nombre único para la propiedad discriminante).
  2. Si la propiedad discriminante tiene un nombre lo suficientemente único, se puede usar para verificar la membresía.

Reconocimiento   

  • Gracias a Kirill Sukhomlin por su sugerencia sobre cómo definir TColorun objeto literal.

Lectura adicional   

  • Un patrón de enumeración basado en clases para JavaScript
  • Enumeraciones de TypeScript: ¿cómo funcionan? ¿Para qué se pueden usar?
  • Agregar valores especiales a los tipos en TypeScript
  • TypeScript: verificaciones de exhaustividad a través de excepciones

No hay comentarios.:

Publicar un comentario

Dejanos tu comentario para seguir mejorando!

outbrain

Páginas