El sistema de tipos de TypeScript se fundamenta en los principios del subtipado estructural (Structural Subtyping). Dos tipos se consideran plenamente equivalentes e intercambiables si comparten el mismo conjunto y tipos de propiedades, con independencia de sus nombres declarados, archivo de origen o propósito de dominio.
En el desarrollo de aplicaciones, el tipado estructural favorece con frecuencia el antipatrón de la obsesión por los primitivos (Primitive Obsession). Si los identificadores de usuario (UserId), pedidos (OrderId) y tokens de sesión (SessionToken) se declaran mediante el tipo básico string, el compilador no puede impedir que se intercambien accidentalmente:
// Error que un compilador con tipado estructural NO puede detectar:
function transferFunds(senderId: string, recipientId: string, amountCents: number) {}
const currentUserId = 'usr_1024';
const targetOrderId = 'ord_9876';
// Bug: ¡pasar OrderId en vez de RecipientId compila sin advertencias!
transferFunds(currentUserId, targetOrderId, 5000);Para subsanar esta vulnerabilidad en el Diseño Guiado por el Dominio (Domain-Driven Design, DDD), se recurre al concepto de tipado nominal (Nominal Typing). En TypeScript se emula mediante tipos con marca (Branded Types u Opaque Types): intersecando un primitivo base con una propiedad fantasma basada en un símbolo inexpresable en tiempo de ejecución.
+-------------------------------------------------------------------------+
| TIPADO ESTRUCTURAL VS TIPADO NOMINAL |
+-------------------------------------------------------------------------+
| [Tipado estructural: Los primitivos son equivalentes] |
| type UserId = string; |
| type OrderId = string; |
| string === string -> Total intercambiabilidad (¡Peligroso en dominio!) |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| [Tipos con marca: Entidades de dominio aisladas] |
| type UserId = string & { readonly [__brand]: 'UserId' }; |
| type OrderId = string & { readonly [__brand]: 'OrderId' }; |
| Compilador: |
| Type '{ [__brand]: "OrderId" }' is not assignable to '{ [__brand]: "UserId" }'|
+-------------------------------------------------------------------------+Un tipo con marca consiste en la intersección (Intersection) de un primitivo base con un tipo fantasma (Phantom Type) que contiene un símbolo único:
Ciclo de vida de un tipo con marca:
[Cadena no confiable de HTTP/BD: string]
|
v
[Smart Constructor / Esquema Zod]
Validación: regex.test(raw), uuid.validate(raw)
/ \
(Éxito) (Fallo)
/ \
v v
[UserId (Brand)] [DomainError / Throw]
|
v
[Los servicios de dominio aceptan ÚNICAMENTE UserId validado]UsdCents, EurCents) para impedir sumas indebidas entre monedas a nivel estático.TenantId, OrganizationId y AccountId, evitando filtraciones de datos por transposición de parámetros en consultas SQL.SanitizedHtml) para proteger contra vulnerabilidades XSS al inyectar en el DOM.EmailAddress, E164PhoneNumber, IsoDateString) sin necesidad de revalidar en cada capa de servicios.Para articular un sistema de marcado robusto, se introduce un símbolo fantasma global:
declare const __brand: unique symbol;
/**
* Tipo utilitario base para generar marcas estrictas (Tipos nominales).
*/
export type Brand<T, TBrand extends string | symbol> = T & {
readonly [__brand]: TBrand;
};
/**
* Alternativa: Marcado suave (Flavoring).
* Permite asignar implícitamente el primitivo base en lectura, pero exige la marca en escritura.
*/
declare const __flavor: unique symbol;
export type Flavor<T, TFlavor extends string | symbol> = T & {
readonly [__flavor]?: TFlavor;
};Aislamiento de identificadores y unidades monetarias en un servicio bancario:
import { Brand } from '../types/brand';
// Declaración de tipos con marca
export type UserId = Brand<string, 'UserId'>;
export type AccountId = Brand<string, 'AccountId'>;
export type UsdCents = Brand<number, 'UsdCents'>;
// Smart Constructor para UserId con validación de UUIDv4
export function parseUserId(raw: string): UserId {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(raw)) {
throw new TypeError(`[DomainError] Formato de UserId no válido: ${raw}`);
}
// Único lugar donde se permite la aserción de tipo: dentro de la fábrica validadora
return raw as UserId;
}
// Smart Constructor para importes monetarios que valida enteros no negativos
export function createUsdCents(amount: number): UsdCents {
if (!Number.isSafeInteger(amount) || amount < 0) {
throw new RangeError(
`[DomainError] El importe en centavos debe ser un entero no negativo. Recibido: ${amount}`
);
}
return amount as UsdCents;
}
// Servicio de dominio con protección contra la transposición de parámetros
export function processTransfer(
fromAccount: AccountId,
toAccount: AccountId,
amount: UsdCents,
authorizedBy: UserId
): void {
// Lógica de negocio de la transferencia
console.log(
`Transferencia de ${amount} centavos de ${fromAccount} a ${toAccount} (autorizado por: ${authorizedBy})`
);
}En producción, los tipos con marca se articulan a menudo con esquemas de validación como Zod:
import { z } from 'zod';
import { Brand } from '../types/brand';
export type UserEmail = Brand<string, 'UserEmail'>;
export const UserEmailSchema = z
.string()
.email('Sintaxis de correo electrónico no válida')
.transform((val) => val.toLowerCase().trim())
.refine((val) => val.length <= 255, 'El correo supera la longitud máxima de 255 caracteres')
// Canalización al tipo con marca personalizado:
.pipe(z.custom<UserEmail>());
export interface RegisterUserDto {
readonly email: UserEmail;
readonly fullName: string;
}
export function parseRegisterUserPayload(input: unknown): RegisterUserDto {
const Schema = z.object({
email: UserEmailSchema,
fullName: z.string().min(2),
});
return Schema.parse(input);
}Recepción de JSON externo de una petición HTTP o base de datos tipado como unknown o string sin validar.
Comprobación de invariantes de negocio (formatos RFC, rangos numéricos, sumas de verificación) en una función aislada.
Tras la validación satisfactoria, el primitivo se convierte a Brand<T, 'Tag'>. En el JS emitido sigue siendo un primitivo nativo.
Los métodos de dominio posteriores solo aceptan UserId. Cualquier intento de pasar un string sin validar emite el error TS2345.
import { Brand } from '../types/brand';
export type USD = Brand<number, 'Currency_USD'>;
export type EUR = Brand<number, 'Currency_EUR'>;
export type GBP = Brand<number, 'Currency_GBP'>;
export type SupportedCurrency = USD | EUR | GBP;
function assertValidAmount(amount: number): void {
if (!Number.isSafeInteger(amount)) {
throw new TypeError(
`[CurrencyError] El importe debe ser un entero seguro en unidades mínimas (centavos). Recibido: ${amount}`
);
}
}
export const Money = {
usd: (cents: number): USD => {
assertValidAmount(cents);
return cents as USD;
},
eur: (cents: number): EUR => {
assertValidAmount(cents);
return cents as EUR;
},
gbp: (cents: number): GBP => {
assertValidAmount(cents);
return cents as GBP;
},
/**
* Suma únicamente valores de la misma divisa.
* ¡Sumar USD y EUR queda físicamente bloqueado por el compilador!
*/
add: <T extends number>(a: Brand<T, string>, b: Brand<T, string>): Brand<T, string> => {
const result = (a as number) + (b as number);
assertValidAmount(result);
return result as Brand<T, string>;
},
/**
* Multiplicación del importe por un factor escalar (tasa impositiva, descuento)
*/
multiply: <T extends number>(amount: Brand<T, string>, factor: number): Brand<T, string> => {
if (!Number.isFinite(factor) || factor < 0) {
throw new RangeError(`[CurrencyError] El factor debe ser un número positivo finito`);
}
const result = Math.round((amount as number) * factor);
assertValidAmount(result);
return result as Brand<T, string>;
},
};Declarar una marca como type UserId = string & { __brand: 'UserId' } expone __brand como una propiedad pública ordinaria. En tiempo de ejecución dicha propiedad no existe, y cualquier desarrollador podría construir accidentalmente literales compatibles.
// ❌ ANTIPATRÓN: La clave pública colisiona en tiempo de ejecución
type BadUserId = string & { _brand: 'UserId' };
// Error en runtime: las cadenas no contienen la propiedad física _brand
const id: BadUserId = '123' as BadUserId;
console.log('_brand' in (id as any)); // false!
// ✅ CORRECTO: Utilización de unique symbol
declare const __brand: unique symbol;
type GoodUserId = string & { readonly [__brand]: 'UserId' };Esparcir aserciones de tipo as UserId en los controladores anula las garantías del tipado nominal. La asignación de la marca debe quedar confinada exclusivamente dentro de los Smart Constructors.
// ❌ ANTIPATRÓN: Aserción manual de una cadena no verificada
const userId = req.body.id as UserId; // BRECHA DE SEGURIDAD: ¡Validación omitida!
// ✅ CORRECTO: Invocación de la fábrica validadora
const userId = parseUserId(req.body.id);Cualquier transformación sobre el primitivo marcado (id.trim(), id.toLowerCase(), amount + 10) devuelve el primitivo genérico (string o number), desechando la marca.
const userId = parseUserId('d3b07384-d113-4672-8597-873b879c464e');
// trim() devuelve string, ¡se pierde la marca!
// const trimmedId: UserId = userId.trim(); // ¡ERROR DE COMPILACIÓN TS2322!
// ✅ CORRECTO: Revalidación o funciones auxiliares específicas
const cleanedUserId = parseUserId(userId.trim());Marcar estructuras compuestas completas suele resultar superfluo y complica la desestructuración de objetos. Las marcas deben aplicarse a escalares atómicos individuales (IDs, claves, importes).
// ❌ ANTIPATRÓN: Marcar el objeto de usuario completo
type BrandedUser = User & Brand<unknown, 'ValidUser'>;
// ✅ CORRECTO: Marcar descriptores primitivos individuales
interface User {
readonly id: UserId;
readonly email: UserEmail;
}Identifique escalares primitivos cuya transposición accidental conlleve riesgos económicos o de seguridad (IDs de usuario, tokens, divisas).
Defina la utilidad Brand<T, Tag> respaldada por declare const __brand: unique symbol.
Escriba funciones fábrica (parseUserId, createUsdCents) que verifiquen los invariantes antes de aplicar la marca.
Sustituya string y number simples en las firmas de métodos por los tipos con marca correspondientes.
Ejecute pnpm typecheck y adapte los puntos de entrada para invocar los constructores inteligentes.