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.
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.
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
typepara la composición (uniones, intersecciones, tipos mapeados) yinterfacepara 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
implementses opcional, pero traslada los errores de contrato a la definición de la clase donde son más fáciles de corregir.