Inicio / Artículos

Enum y interfaces de TypeScript como bloques fundamentales para el diseño de bajo nivel

Cómo los enums de TypeScript eliminan los errores relacionados con cadenas mágicas, cuándo elegir tipo o interfaz, y cómo las interfaces, implements y el tipado estructural definen contratos en el diseño de bajo nivel.

Editor: REACTAPP.TOP7 min de lectura1599 palabras

Fuentes: Day 1: (Enums and Interfaces) LLD With TS | Part 2

Después de las clases y los objetos, las siguientes herramientas para el diseño de bajo nivel (LLD) en TypeScript son los enums e interfaces. Pueden parecer triviales, pero mantienen la consistencia del diseño: los enums definen los valores permitidos, mientras que las interfaces establecen los contratos de los objetos. Este artículo muestra los errores que cada uno previene, cómo elegir entre type y interface, y qué significa el tipado estructural para implements.

El problema que resuelven los enums: cadenas mágicas dispersas

Supongamos que un pedido en su aplicación puede estar en uno de tres estados:

PENDING
SUCCESS
REJECTED

El código en un módulo verifica si hay un pedido pendiente de esta manera:

if (order.status === "PENDING") {
  // ...
}

Otro módulo verifica si el pedido fue exitoso:

if (order.status === "SUCCESS") {
  // ...
}

Todo funciona hasta que un nuevo compañero de equipo escribe una verificación casi idéntica pero usando minúsculas:

if (order.status === "pending") {
  // ...
}

La comparación de cadenas es sensible a mayúsculas y minúsculas, por lo que estas son valores diferentes:

"PENDING" !== "pending"

La condición nunca se cumple en silencio. La causa raíz es que los valores se escriben a mano en todas partes: ¿quién recuerda si era PENDING, pending o Pending?

Definir los valores permitidos una sola vez

Un enum declara un conjunto fijo de valores nombrados en un solo lugar para que el resto del código pueda hacer referencia a ellos por su nombre. Aquí cada miembro recibe explícitamente su valor de cadena:

enum OrderStatus {
  PENDING = "PENDING",
  SUCCESS = "SUCCESS",
  REJECTED = "REJECTED"
}

En lugar de comparar con una cadena sin formato, como en esta comprobación,

if (order.status === "PENDING") {
  // ...
}

se compara con el miembro del enum:

if (order.status === OrderStatus.PENDING) {
  // ...
}

Lo mismo aplica a todas las demás comprobaciones de estado:

if (order.status === OrderStatus.SUCCESS) {
  // ...
}

Nadie tiene que recordar la ortografía exacta, solo el nombre del miembro, que el editor completa automáticamente:

OrderStatus.PENDING

Un error tipográfico en el nombre de un miembro ahora es un error en tiempo de compilación en lugar de una discrepancia silenciosa en tiempo de ejecución.

Por qué es importante a medida que crece la base de código

Imagínese que el estado aparece en veinte lugares. Sin un enum, la consistencia depende de la disciplina, y las variaciones aparecen gradualmente:

"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"

Con un enum, cada ubicación hace referencia a la misma declaración:

OrderStatus.PENDING

En resumen, un enum es un conjunto de constantes predefinidas y nombradas.

Los enums de cadena son más fáciles de depurar que los numéricos porque sus valores permanecen legibles en los registros. Una unión de literales de cadena, mostrada a continuación, ofrece una seguridad similar sin código en tiempo de ejecución, lo cual prefieren algunos equipos.

tipo versus interfaz

Para exámenes y entrevistas, basta con un modelo mental simple de los dos.

tipo: composición y modelado de tipos

  • Flexible para combinar tipos.
  • Maneja uniones, intersecciones, tuplas, primitivas, tipos mapeados y más.
  • Es adecuado cuando se trata principalmente de describir o componer datos.
  • Un ejemplo típico es una unión de cadenas literales:

    type Status = "PENDING" | "SUCCESS" | "FAILED";
    

    También lo es un identificador que puede ser una cadena o un número:

    type ID = string | number;
    

    interface: contratos para objetos y clases

    • Describe principalmente la estructura de los objetos y los requisitos que deben cumplir las clases.
    • Puede ser implementado por una clase con implements.
    • Es adecuado cuando varias implementaciones deben cumplir con el mismo contrato.
    • Soporta la fusión de declaraciones, por lo que dos declaraciones con el mismo nombre se combinan en una.
    interface Payment {
      amount: number;
      pay(): void;
    }
    

    Una regla práctica

    Cuando necesite componer o modelar un tipo, utilice type:

    Need to compose/model a type?
            ↓
          type
    

    Cuando necesite un contrato entre clases u objetos, recurra a interface:

    Need a contract between classes/objects?
            ↓
        interface
    

    Es una guía, no una ley: ambas pueden describir la estructura de los objetos.

    Las interfaces describen la estructura del objeto

    La forma más sencilla de acercarse a las interfaces es pensar en la estructura de un objeto. Aquí tiene un objeto simple de JavaScript:

    const student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    TypeScript le permite describir esa estructura, por ejemplo con un alias de tipo:

    type Student = {
      id: number;
      name: string;
      age: number;
    };
    

    Entonces, varios objetos pueden ser verificados contra la misma estructura:

    const student1: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    
    const student2: Student = {
      id: 102,
      name: "Aman",
      age: 21
    };
    

    A medida que la aplicación crece, nombrar esa estructura como un contrato reutilizable es precisamente para lo que sirve una interfaz.

    Una interfaz como contrato

    La misma estructura expresada como una interfaz:

    interface Student {
      id: number;
      name: string;
      age: number;
    }
    

    Cualquier valor que deba tratarse como Student debe cumplir con sus requisitos:

    const student: Student = {
      id: 101,
      name: "Rahul",
      age: 20
    };
    

    Se omite una propiedad requerida, como aquí,

    const student: Student = {
      id: 101,
      name: "Rahul"
    };
    

    y el compilador reporta un error porque falta esta propiedad:

    age
    

    En efecto, la interfaz indica: para ser tratada como este tipo, se debe cumplir con este contrato.

    Las interfaces definen qué, no cómo

    Esta distinción es fundamental en LLD. Una interfaz establece qué debe tener o proporcionar algo, no cómo lo hace. Tomemos un contrato de pago:

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    Garantiza exactamente dos cosas:

    Payment must have:
    → amount
    → pay()
    

    No dice nada sobre cómo funciona pay(). Una clase podría procesar una tarjeta, comenzando con la propiedad requerida amount,

    class CreditCardPayment implements Payment {
      amount: number;
    

    y proporcionar su propio pay():

      pay() {
        console.log("Paying using credit card");
      }
    }
    

    Otra podría pagar a través de UPI, el sistema de pagos instantáneos de India, utilizando la misma propiedad.

    class UPIPayment implements Payment {
      amount: number;
    

    y un pay() diferente:

      pay() {
        console.log("Paying using UPI");
      }
    }
    

    Mismo contrato, implementaciones diferentes, por lo que los llamantes pueden trabajar con cualquier tipo de pago:

    Interface
       ↓
    Defines the contract
       ↓
    Multiple implementations
       ↓
    Different implementation details
    

    La palabra clave implements

    implements hace explícita la relación. Dado el contrato,

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    una clase declara que lo cumple:

    class CreditCardPayment implements Payment {
      amount: number;  pay() {
        console.log("Paying with credit card");
      }
    }
    

    La cláusula

    implements Payment
    

    pide al compilador que verifique si la clase satisface el contrato Payment. Si no lo hace, por ejemplo porque falta pay(),

    class CreditCardPayment implements Payment {
      amount: number;
    }
    

    el compilador marca como ausente

    pay()
    

    El método implements es únicamente una verificación en tiempo de compilación; no agrega nada en tiempo de ejecución. Un detalle al probar estos fragmentos: con strictPropertyInitialization activado, un campo declarado como amount: number; sin inicializador ni asignación en el constructor ya constituye un error, por lo que se debe asignarle un valor.

    Tipado estructural: la forma supera a las declaraciones

    A diferencia de Java o C#, TypeScript es de tipado estructural. Una clase no necesita declarar

    implements Payment
    

    para ser aceptado donde se espera un Payment. Considere un contrato con un único método:

    interface Payment {
      pay(): void;
    }
    

    y una clase que nunca lo menciona:

    class UPIPayment {
      pay() {
        console.log("Paying through UPI");
      }
    }
    

    Incluso sin escribir

    implements Payment
    

    una función que espera un Payment

    function processPayment(payment: Payment) {
      payment.pay();
    }
    

    acepta felizmente una instancia de esa clase:

    const payment = new UPIPayment();processPayment(payment);
    

    Esto se compila porque UPIPayment tiene la forma requerida:

    {
      pay(): void;
    }
    

    En TypeScript, basta con que la forma coincida; la declaración explícita es opcional.

    Entonces, ¿por qué escribir “implements” en absoluto?

    Si el tipado estructural ya acepta clases que coinciden, implements podría parecer redundante. Su valor radica en que verifica el contrato donde se define la clase, no solo donde se utiliza. Dado el contrato

    interface Payment {
      amount: number;
      pay(): void;
    }
    

    y una clase que lo implementa explícitamente,

    class UPIPayment implements Payment {
      amount = 100;  pay() {
        console.log("Paying through UPI");
      }
    }
    

    supongamos que alguien más tarde extiende la interfaz con una moneda:

    interface Payment {
      amount: number;
      currency: string;
      pay(): void;
    }
    

    El compilador informa de inmediato que UPIPayment ya no cumple con el contrato, señalando a la propia clase. Sin él, el error aparecería solo en algún lugar remoto de las llamadas, o no aparecería en absoluto.

    Puntos clave

    • Los enums reemplazan cadenas mágicas dispersas por una sola declaración, convirtiendo errores de escritura en errores de compilación.
    • Las uniones de literales de cadena son una alternativa ligera cuando no se necesita un objeto en tiempo de ejecución.
    • Use type para la composición (uniones, intersecciones, tipos mapeados) y interface para los contratos que implementan las clases.
    • Las interfaces describen qué proporciona un objeto, nunca cómo, lo que permite implementaciones intercambiables.
    • TypeScript verifica las estructuras de forma estructural, por lo que implements es opcional, pero traslada los errores de contrato a la definición de la clase donde son más fáciles de corregir.