Explora los tipos 'branded' de TypeScript, una técnica potente para lograr el tipado nominal en un sistema de tipos estructural. Aprende a mejorar la seguridad y claridad del código.
Tipos 'Branded' en TypeScript: Tipado Nominal en un Sistema Estructural
El sistema de tipos estructural de TypeScript ofrece flexibilidad, pero a veces puede llevar a comportamientos inesperados. Los tipos 'branded' (o de marca) proporcionan una forma de forzar el tipado nominal, mejorando la seguridad de tipos y la claridad del código. Este artículo explora los tipos 'branded' en detalle, proporcionando ejemplos prácticos y mejores prácticas para su implementación.
Entendiendo el Tipado Estructural vs. Nominal
Antes de sumergirnos en los tipos 'branded', aclaremos la diferencia entre el tipado estructural y el nominal.
Tipado Estructural (Duck Typing)
En un sistema de tipos estructural, dos tipos se consideran compatibles si tienen la misma estructura (es decir, las mismas propiedades con los mismos tipos). TypeScript utiliza el tipado estructural. Considera este ejemplo:
interface Point {
x: number;
y: number;
}
interface Vector {
x: number;
y: number;
}
const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Válido en TypeScript
console.log(vector.x); // Salida: 10
Aunque Point
y Vector
se declaran como tipos distintos, TypeScript permite asignar un objeto Point
a una variable Vector
porque comparten la misma estructura. Esto puede ser conveniente, pero también puede llevar a errores si necesitas distinguir entre tipos lógicamente diferentes que casualmente tienen la misma forma. Por ejemplo, pensar en coordenadas de latitud/longitud que podrían coincidir incidentalmente con las coordenadas de píxeles de una pantalla.
Tipado Nominal
En un sistema de tipos nominal, los tipos se consideran compatibles solo si tienen el mismo nombre. Incluso si dos tipos tienen la misma estructura, se tratan como distintos si tienen nombres diferentes. Lenguajes como Java y C# utilizan el tipado nominal.
La Necesidad de los Tipos 'Branded'
El tipado estructural de TypeScript puede ser problemático cuando necesitas asegurar que un valor pertenece a un tipo específico, independientemente de su estructura. Por ejemplo, considera la representación de monedas. Podrías tener diferentes tipos para USD y EUR, pero ambos podrían representarse como números. Sin un mecanismo para distinguirlos, podrías realizar accidentalmente operaciones con la moneda equivocada.
Los tipos 'branded' abordan este problema permitiéndote crear tipos distintos que son estructuralmente similares pero tratados como diferentes por el sistema de tipos. Esto mejora la seguridad de tipos y previene errores que de otro modo podrían pasar desapercibidos.
Implementando Tipos 'Branded' en TypeScript
Los tipos 'branded' se implementan usando tipos de intersección y un símbolo único o un literal de cadena. La idea es añadir una "marca" a un tipo que lo distinga de otros tipos con la misma estructura.
Usando Símbolos (Recomendado)
Generalmente se prefiere usar símbolos para el 'branding' porque se garantiza que los símbolos son únicos.
const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };
const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);
// Descomentar la siguiente línea causará un error de tipo
// const invalidOperation = addUSD(usd1, eur1);
En este ejemplo, USD
y EUR
son tipos 'branded' basados en el tipo number
. El unique symbol
asegura que estos tipos sean distintos. Las funciones createUSD
y createEUR
se utilizan para crear valores de estos tipos, y la función addUSD
solo acepta valores USD
. Intentar sumar un valor EUR
a un valor USD
resultará en un error de tipo.
Usando Literales de Cadena
También puedes usar literales de cadena para el 'branding', aunque este enfoque es menos robusto que usar símbolos porque no se garantiza que los literales de cadena sean únicos.
type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };
function createUSD(value: number): USD {
return value as USD;
}
function createEUR(value: number): EUR {
return value as EUR;
}
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);
const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);
// Descomentar la siguiente línea causará un error de tipo
// const invalidOperation = addUSD(usd1, eur1);
Este ejemplo logra el mismo resultado que el anterior, pero usando literales de cadena en lugar de símbolos. Aunque es más simple, es importante asegurarse de que los literales de cadena utilizados para el 'branding' sean únicos dentro de tu código base.
Ejemplos Prácticos y Casos de Uso
Los tipos 'branded' pueden aplicarse a diversos escenarios donde necesitas forzar la seguridad de tipos más allá de la compatibilidad estructural.
Identificadores (IDs)
Considera un sistema con diferentes tipos de identificadores, como UserID
, ProductID
y OrderID
. Todos estos IDs podrían representarse como números o cadenas, pero quieres evitar mezclar accidentalmente diferentes tipos de ID.
const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };
const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };
function getUser(id: UserID): { name: string } {
// ... obtener datos del usuario
return { name: "Alice" };
}
function getProduct(id: ProductID): { name: string, price: number } {
// ... obtener datos del producto
return { name: "Example Product", price: 25 };
}
function createUserID(id: string): UserID {
return id as UserID;
}
function createProductID(id: string): ProductID {
return id as ProductID;
}
const userID = createUserID('user123');
const productID = createProductID('product456');
const user = getUser(userID);
const product = getProduct(productID);
console.log("User:", user);
console.log("Product:", product);
// Descomentar la siguiente línea causará un error de tipo
// const invalidCall = getUser(productID);
Este ejemplo demuestra cómo los tipos 'branded' pueden evitar que se pase un ProductID
a una función que espera un UserID
, mejorando la seguridad de tipos.
Valores Específicos del Dominio
Los tipos 'branded' también pueden ser útiles para representar valores específicos del dominio con restricciones. Por ejemplo, podrías tener un tipo para porcentajes que siempre deben estar entre 0 y 100.
const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };
function createPercentage(value: number): Percentage {
if (value < 0 || value > 100) {
throw new Error('El porcentaje debe estar entre 0 y 100');
}
return value as Percentage;
}
function applyDiscount(price: number, discount: Percentage): number {
return price * (1 - discount / 100);
}
try {
const discount = createPercentage(20);
const discountedPrice = applyDiscount(100, discount);
console.log("Precio con descuento:", discountedPrice);
// Descomentar la siguiente línea causará un error en tiempo de ejecución
// const invalidPercentage = createPercentage(120);
} catch (error) {
console.error(error);
}
Este ejemplo muestra cómo forzar una restricción sobre el valor de un tipo 'branded' en tiempo de ejecución. Aunque el sistema de tipos no puede garantizar que un valor Percentage
esté siempre entre 0 y 100, la función createPercentage
puede forzar esta restricción en tiempo de ejecución. También puedes usar librerías como io-ts para forzar la validación en tiempo de ejecución de los tipos 'branded'.
Representaciones de Fecha y Hora
Trabajar con fechas y horas puede ser complicado debido a los diversos formatos y zonas horarias. Los tipos 'branded' pueden ayudar a diferenciar entre distintas representaciones de fecha y hora.
const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };
const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };
function createUTCDate(dateString: string): UTCDate {
// Validar que la cadena de fecha esté en formato UTC (p. ej., ISO 8601 con Z)
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
throw new Error('Formato de fecha UTC inválido');
}
return dateString as UTCDate;
}
function createLocalDate(dateString: string): LocalDate {
// Validar que la cadena de fecha esté en formato de fecha local (p. ej., YYYY-MM-DD)
if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
throw new Error('Formato de fecha local inválido');
}
return dateString as LocalDate;
}
function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
// Realizar conversión de zona horaria
const date = new Date(utcDate);
const localDateString = date.toLocaleDateString();
return createLocalDate(localDateString);
}
try {
const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
const localDate = convertUTCDateToLocalDate(utcDate);
console.log("Fecha UTC:", utcDate);
console.log("Fecha Local:", localDate);
} catch (error) {
console.error(error);
}
Este ejemplo diferencia entre fechas UTC y locales, asegurando que estás trabajando con la representación de fecha y hora correcta en diferentes partes de tu aplicación. La validación en tiempo de ejecución asegura que solo las cadenas de fecha con el formato correcto puedan asignarse a estos tipos.
Mejores Prácticas para Usar Tipos 'Branded'
Para usar eficazmente los tipos 'branded' en TypeScript, considera las siguientes mejores prácticas:
- Usa Símbolos para el 'Branding': Los símbolos proporcionan la garantía más fuerte de unicidad, reduciendo el riesgo de errores de tipo.
- Crea Funciones Auxiliares: Usa funciones auxiliares para crear valores de tipos 'branded'. Esto proporciona un punto central para la validación y asegura la consistencia.
- Aplica Validación en Tiempo de Ejecución: Aunque los tipos 'branded' mejoran la seguridad de tipos, no evitan que se asignen valores incorrectos en tiempo de ejecución. Usa la validación en tiempo de ejecución para forzar las restricciones.
- Documenta los Tipos 'Branded': Documenta claramente el propósito y las restricciones de cada tipo 'branded' para mejorar la mantenibilidad del código.
- Considera las Implicaciones de Rendimiento: Los tipos 'branded' introducen una pequeña sobrecarga debido al tipo de intersección y la necesidad de funciones auxiliares. Considera el impacto en el rendimiento en secciones críticas de tu código.
Ventajas de los Tipos 'Branded'
- Seguridad de Tipos Mejorada: Previene la mezcla accidental de tipos estructuralmente similares pero lógicamente diferentes.
- Claridad del Código Mejorada: Hace el código más legible y fácil de entender al diferenciar explícitamente entre tipos.
- Reducción de Errores: Atrapa errores potenciales en tiempo de compilación, reduciendo el riesgo de bugs en tiempo de ejecución.
- Mantenibilidad Aumentada: Hace que el código sea más fácil de mantener y refactorizar al proporcionar una clara separación de responsabilidades.
Desventajas de los Tipos 'Branded'
- Complejidad Aumentada: Añade complejidad a la base del código, especialmente al tratar con muchos tipos 'branded'.
- Sobrecarga en Tiempo de Ejecución: Introduce una pequeña sobrecarga en tiempo de ejecución debido a la necesidad de funciones auxiliares y validación en tiempo de ejecución.
- Potencial para Código Repetitivo (Boilerplate): Puede llevar a código repetitivo, especialmente al crear y validar tipos 'branded'.
Alternativas a los Tipos 'Branded'
Aunque los tipos 'branded' son una técnica potente para lograr el tipado nominal en TypeScript, existen enfoques alternativos que podrías considerar.
Tipos Opacos
Los tipos opacos son similares a los tipos 'branded' pero proporcionan una forma más explícita de ocultar el tipo subyacente. TypeScript no tiene soporte nativo para tipos opacos, pero puedes simularlos usando módulos y símbolos privados.
Clases
Usar clases puede proporcionar un enfoque más orientado a objetos para definir tipos distintos. Aunque las clases tienen tipado estructural en TypeScript, ofrecen una separación de responsabilidades más clara y pueden usarse para forzar restricciones a través de métodos.
Librerías como `io-ts` o `zod`
Estas librerías proporcionan una validación de tipos sofisticada en tiempo de ejecución y pueden combinarse con tipos 'branded' para asegurar la seguridad tanto en tiempo de compilación como de ejecución.
Conclusión
Los tipos 'branded' de TypeScript son una herramienta valiosa para mejorar la seguridad de tipos y la claridad del código en un sistema de tipos estructural. Al añadir una "marca" a un tipo, puedes forzar el tipado nominal y prevenir la mezcla accidental de tipos estructuralmente similares pero lógicamente diferentes. Aunque los tipos 'branded' introducen cierta complejidad y sobrecarga, los beneficios de una mayor seguridad de tipos y mantenibilidad del código a menudo superan las desventajas. Considera usar tipos 'branded' en escenarios donde necesites asegurar que un valor pertenece a un tipo específico, independientemente de su estructura.
Al entender los principios detrás del tipado estructural y nominal, y al aplicar las mejores prácticas descritas en este artículo, puedes aprovechar eficazmente los tipos 'branded' para escribir código TypeScript más robusto y mantenible. Desde representar monedas e IDs hasta forzar restricciones específicas del dominio, los tipos 'branded' proporcionan un mecanismo flexible y potente para mejorar la seguridad de tipos en tus proyectos.
A medida que trabajes con TypeScript, explora las diversas técnicas y librerías disponibles para la validación y el forzado de tipos. Considera usar tipos 'branded' junto con librerías de validación en tiempo de ejecución como io-ts
o zod
para lograr un enfoque integral de la seguridad de tipos.