Patrones de seguridad de tipos en TypeScript: Generics, DI y cuándo un patrón resulta útil
Patrones de diseño y TypeScript a nivel de tipo para la seguridad tipológica: generics, inyección de dependencias, IDs con marca, y cómo saber cuándo un patrón reduce la complejidad.
La mayoría de los equipos adoptan TypeScript para el autocompletado y la detección de errores de escritura, y luego descubren su verdadero valor: permite codificar decisiones de diseño de modo que el compilador las haga cumplir. Esta guía explica los patrones orientados a objetos clásicos y las técnicas a nivel de tipos que son más importantes en bases de código front-end y full-stack grandes, y muestra cómo decidir cuándo vale la pena utilizar un patrón.
Considere el sistema de tipos como algo que puede responder a preguntas arquitectónicas:
- ¿Es realmente posible esta combinación de valores de estado?
- ¿Podría esta API devolver una estructura que el resto del código no espera?
- ¿Puede un
ProductIdpasar a una función que espera unUserId? - ¿Se pueden asignar a un componente propiedades que se contradicen entre sí?
- Si se agrega un nuevo estado, ¿se verá obligado cada consumidor a manejarlo?
any?Un patrón no es una característica que se añade posteriormente. Es el nombre de una solución que se reconoce una vez aparece un problema recurrente.
Cuatro familias de patrones, más el sistema de tipos
La división clásica incluye patrones creacionales, estructurales y de comportamiento; TypeScript agrega una cuarta familia de técnicas a nivel de tipos basadas en genericos, uniones y herramientas de seguridad.
TYPESCRIPT PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
CREATIONAL STRUCTURAL BEHAVIORAL
│ │ │
├─ Factory ├─ Adapter ├─ Strategy
├─ Builder ├─ Facade ├─ Observer
├─ Singleton ├─ Decorator ├─ Command
└─ Abstract Factory ├─ Repository └─ State
└─ Composition
+
TYPESCRIPT TYPE PATTERNS
│
┌───────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
Generics Unions Type Safety
│ │ │
├─ Constraints ├─ Discriminated ├─ Type Guards
├─ keyof Unions ├─ Branded Types
├─ typeof ├─ Result Types ├─ Exhaustiveness
├─ infer └─ State Modeling └─ satisfies
└─ Mapped Types
Las aplicaciones reales rara vez utilizan un único patrón. Un flujo de datos típico en React combina varios de ellos, y cada límite puede tener tipos específicos:
React Component
│
▼
Custom Hook
│
▼
Service
│
▼
Repository
│
▼
API Client
│
▼
Result<T, E>
│
▼
Discriminated Union
Cuando los tipos fluyen a través de toda esa cadena, TypeScript se convierte en una descripción de cómo se permite que el sistema se comporte.
Comience con el problema, no con el patrón
Una trampa común es razonar en la dirección incorrecta:
"I know Factory Pattern.
Where can I use Factory?"
Elegir un patrón primero genera abstracciones que nadie necesita. Deje que el problema guíe la elección en su lugar:
What problem do I have?
↓
Where is the complexity?
↓
What is changing frequently?
↓
What should remain stable?
↓
What abstraction reduces that complexity?
↓
Is a known pattern appropriate?
Considere un flujo de pago que incluye una serie de verificaciones en el método de pago:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
La respuesta refleja suele ser esta:
Don’t immediately think:
“Esto requiere una Estrategia”. Antes de abordarlo, pregúntese si las ramas son realmente algoritmos intercambiables detrás de un mismo contrato. Si es así, la Estrategia es adecuada. Si el problema real es que algunos campos solo tienen sentido para ciertos métodos, una unión discriminada que impida la representación de combinaciones inválidas podría ser la herramienta más adecuada. Conocer el catálogo es fácil; la habilidad radica en asociar una entrada a una presión específica.
Patrones de creación
Singleton: una instancia compartida
Un Singleton garantiza que una clase tenga exactamente una instancia. El constructor privado impide su uso desde fuera de new, y un método estático crea y almacena la instancia de forma perezosa:
class Logger {
private static instance: Logger;
private constructor() {}
static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
log(message: string) {
console.log(message);
}
}
Los usuarios solicitan el objeto compartido en lugar de crear uno:
const logger = Logger.getInstance();
logger.log("Application started");
Cada consumidor termina señalando el mismo objeto:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Los candidatos razonables incluyen:
- gestores de registros
- gestores de análisis
- responsables de la configuración
- algunos gestores de conexiones
- otros servicios de infraestructura transversales
El problema es que un Singleton es en realidad un acceso global disfrazado: los tests resultan más difíciles de aislar, las dependencias desaparecen de las firmas, el ciclo de vida se vuelve confuso y el estado mutable compartido cambia de formas imposibles de rastrear. En un frontend moderno, una instancia exportada desde un módulo ES ya está compartida, y la inyección de dependencias, React Context o una biblioteca de estado ofrecen la misma garantía con una estructura visible.
Fábrica: ocultar qué clase se crea
Una Fábrica separa la decisión sobre qué clase concreta se va a instanciar del consumidor. Compare con la construcción directa:
const payment = new StripePayment();
con construcción delegada:
const payment = PaymentFactory.create("stripe");
Ambos proveedores implementan una misma interfaz, y la fábrica asocia una unión de literales de cadena con la clase correspondiente. Dado que el tipo del parámetro es "stripe" | "paypal", un nombre no soportado impide la compilación:
interface PaymentProvider {
pay(amount: number): Promise<void>;
}
class StripePayment implements PaymentProvider {
async pay(amount: number) {
console.log("Stripe:", amount);
}
}
class PayPalPayment implements PaymentProvider {
async pay(amount: number) {
console.log("PayPal:", amount);
}
}
class PaymentFactory {
static create(
provider: "stripe" | "paypal"
): PaymentProvider {
switch (provider) {
case "stripe":
return new StripePayment();
case "paypal":
return new PayPalPayment();
}
}
}
Visualmente, la fábrica se representa con un tenedor cuyo extremo lleva el nombre del proveedor:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Una fábrica resulta útil cuando:
- la construcción sigue una lógica real
- varias implementaciones comparten un mismo contrato
- los consumidores no deben conocer las clases concretas
- las implementaciones deben evolucionar de forma independiente de quienes las llaman
En casos de construcción trivial como el de la línea a continuación, solo se añade una capa adicional que hay que analizar:
new User();
Fábrica abstracta: familias de objetos relacionados
La Fábrica Abstracta extiende la idea a grupos de objetos que deben ser compatibles entre sí. En un kit de interfaz de usuario destinado a varias plataformas, un botón web nunca debe combinarse con un modal móvil, por lo que cada fábrica genera una familia coherente:
interface Button {
render(): void;
}
interface Modal {
open(): void;
}
interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
UIFactory
│
┌────────┴────────┐
▼ ▼
WebUIFactory MobileUIFactory
│ │
┌────┴────┐ ┌────┴────┐
▼ ▼ ▼ ▼
Button Modal Button Modal
Es potente, pero es fácil sobreconstruirlo. En la mayoría de las aplicaciones front-end, la composición simple de componentes permite lograr el mismo resultado con menos formalidades.
Builder: construcción controlada, paso a paso
Builder resulta útil cuando un objeto tiene muchas configuraciones opcionales o se configura en etapas. Cada método de configuración actualiza la configuración privada y devuelve this, lo que permite el encadenamiento:
class RequestBuilder {
private config: RequestInit = {};
setMethod(method: string) {
this.config.method = method;
return this;
}
setHeaders(headers: HeadersInit) {
this.config.headers = headers;
return this;
}
setBody(body: BodyInit) {
this.config.body = body;
return this;
}
build() {
return this.config;
}
}
El montaje de una solicitud se presenta como pasos explícitos:
const request = new RequestBuilder()
.setMethod("POST")
.setHeaders({
"Content-Type": "application/json"
})
.setBody(JSON.stringify(data))
.build();
Una sintaxis fluida es un efecto secundario, no el objetivo. Lo importante es mantener las construcciones complejas explícitas y en un solo lugar, donde build() también pueda validarlas, por ejemplo, rechazando un cuerpo en una solicitud GET.
Patrones estructurales
Adapter: un contrato estable alrededor de la API de otro
El adapter es uno de los patrones más utilizados en el código de aplicaciones. Supongamos que su código espera este contrato interno:
interface PaymentGateway {
pay(amount: number): Promise<void>;
}
mientras que un SDK de terceros expone un método con un nombre diferente:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
Un adaptador delgado implementa tu interfaz y traduce las llamadas:
class PaymentAdapter implements PaymentGateway {
constructor(
private readonly sdk: LegacyPaymentSDK
) {}
async pay(amount: number) {
this.sdk.makePayment(amount);
}
}
Application
│
▼
PaymentGateway
▲
│
PaymentAdapter
│
▼
Third-party SDK
La aplicación ahora depende de una interfaz que usted posee:
PaymentGateway
en lugar de en cada proveedor que lo respalda:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Apoyar a un proveedor nuevo implica escribir otro adaptador.
Facade: una sola llamada para un flujo de trabajo de múltiples pasos
Una Facade proporciona un punto de entrada sencillo frente a un subsistema complejo. El inicio de sesión podría involucrar varios servicios:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Sin una fachada, cada componente que inicia sesión a un usuario orquesta la secuencia por sí mismo:
auth.login();
user.load();
permission.load();
analytics.track();
Una fachada envuelve esa orquestación una sola vez:
class AppFacade {
async login(username: string, password: string) {
const token = await auth.login(username, password);
const user = await userService.getUser(token);
await permissionService.load(user);
analytics.track("login");
return user;
}
}
y el componente se reduce a una sola llamada:
await appFacade.login(username, password);
Component
│
▼
AppFacade
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Auth User Permission
Service Service Service
Esto es útil cuando el orden de los pasos es importante.
Decorador: agregar comportamiento mediante envoltura
Un Decorador agrega comportamiento sin cambiar la implementación original, ya que el objeto envolvente y el objeto envuelto comparten una misma interfaz. El contrato es el siguiente:
interface Logger {
log(message: string): void;
}
Una implementación sencilla:
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
}
Un decorador que almacena cualquier Logger y añade un sello de tiempo al principio de los mensajes:
class TimestampLogger implements Logger {
constructor(
private readonly logger: Logger
) {}
log(message: string) {
this.logger.log(
`[${new Date().toISOString()}] ${message}`
);
}
}
El envoltorio es simplemente una estructura:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Dado que cada decorador es en sí mismo un Logger, se apilan:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
La misma idea aparece bajo otros nombres:
- cadenas de middleware
- funciones de envoltura
- Componentes de orden superior en React
- registro de actividades
- capas de caché
- verificaciones de autorización
- instrumentación y seguimiento
Patrones de comportamiento
Estrategia: algoritmos intercambiables
La estrategia elimina las reglas de negocio con ramificaciones. Tomemos un tipo de nivel de cliente:
type CustomerType =
| "regular"
| "premium"
| "enterprise";
Una implementación condicional coloca cada regla en una función:
function calculateDiscount(
type: CustomerType,
price: number
) {
if (type === "regular") {
return price;
}
if (type === "premium") {
return price * 0.9;
}
return price * 0.8;
}
Con Strategy, cada regla se convierte en una clase detrás de una interfaz compartida:
interface DiscountStrategy {
calculate(price: number): number;
}
class RegularDiscount implements DiscountStrategy {
calculate(price: number) {
return price;
}
}
class PremiumDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.9;
}
}
class EnterpriseDiscount implements DiscountStrategy {
calculate(price: number) {
return price * 0.8;
}
}
Order
│
▼
DiscountStrategy
│
┌───────────┼───────────┐
▼ ▼ ▼
Regular Premium Enterprise
Strategy Strategy Strategy
Añadir un nivel ahora generalmente implica incorporar una estrategia en lugar de editar la lógica existente, lo cual representa en la práctica el Principio de Apertura/Cierre. Con tres reglas muy simples, la condicional es suficiente; la estrategia resulta útil cuando las reglas desarrollan sus propias dependencias o pruebas.
Observer: notificaciones de uno a muchos
Observer permite que un sujeto notifique a cualquier cantidad de oyentes cuando algo cambia:
Subject
│
┌──────────┼──────────┐
▼ ▼ ▼
Observer A Observer B Observer C
Un pequeño emisor tipado almacena a los oyentes en un Set y devuelve una función de limpieza cuando se registra un oyente:
type Listener<T> = (value: T) => void;
class EventEmitter<T> {
private listeners = new Set<Listener<T>>();
subscribe(listener: Listener<T>) {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
emit(value: T) {
this.listeners.forEach(listener => {
listener(value);
});
}
}
const emitter = new EventEmitter<string>();
const unsubscribe = emitter.subscribe(message => {
console.log(message);
});
emitter.emit("Hello");
unsubscribe();
Esa función de limpieza es parte de la gestión del ciclo de vida. Olvidarse de llamarla conlleva:
- fugas de memoria debido a los listeners que sobreviven a sus propietarios
- manejo duplicado cuando un listener se adjunta dos veces
- cierres obsoletos que leen valores desactualizados
- efectos secundarios que se activan después de que un componente haya desaparecido
En React, es exactamente lo que se devuelve desde la función de callback de useEffect.
Comando: acciones como objetos
Command convierte una acción en un objeto con una interfaz uniforme:
interface Command {
execute(): void;
}
Los comandos concretos lo implementan:
class SaveCommand implements Command {
execute() {
console.log("Saving...");
}
}
class UndoCommand implements Command {
execute() {
console.log("Undo");
}
}
Tratar las acciones como valores es útil cuando necesitas:
- un historial de lo que ocurrió
- deshacer y rehacer
- ejecución en cola
- reintentos
- ejecución diferida
- registro de auditoría
El verdadero deshacer suele requerir que cada comando se invierta a sí mismo, por lo que las versiones de producción a menudo añaden un método undo() junto a execute().
User Action
│
▼
Command
│
├── execute()
│
▼
Receiver
Patrones de datos y composición
Repository: aislar el acceso a los datos
Cuando el acceso a los datos deja de ser sencillo, un Repository establece un contrato entre la lógica de negocio y todo aquello que almacena o obtiene los datos:
UI
│
▼
Hook / Controller
│
▼
Service
│
▼
Repository
│
├── REST
├── GraphQL
├── IndexedDB
└── Cache
El contrato describe qué puede solicitar la aplicación:
interface UserRepository {
getUser(id: string): Promise<User>;
getUsers(): Promise<User[]>;
}
Una implementación se comunica con una API REST:
class ApiUserRepository implements UserRepository {
async getUser(id: string) {
const response = await fetch(`/users/${id}`);
return response.json();
}
async getUsers() {
const response = await fetch("/users");
return response.json();
}
}
El código de negocio depende de la interfaz:
UserRepository
no en un medio de transporte:
fetch()
axios()
graphqlClient()
Esto es importante cuando cambia el origen: de REST a GraphQL, la adición de un caché IndexedDB o un modelo en memoria para pruebas. Tenga en cuenta que response.json() devuelve un valor sin tipado, por lo que el repositorio también es el lugar adecuado para validar las respuestas.
Composición sobre herencia
En el trabajo frontend, la composición es más importante que cualquier patrón basado en herencia. En lugar de tener un único componente que controle todo:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
divida las responsabilidades en tareas específicas:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
En React esto se logra simplemente anidando componentes:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Cada componente puede ser comprendido, probado y reemplazado por separado. Para obtener más información sobre cómo dividir componentes demasiado complejos, consulte cómo solucionar la sobrecarga de propiedades en React con composición y slots.
Modelado del estado con el sistema de tipos
A partir de aquí, TypeScript en sí mismo es la herramienta arquitectónica.
Uniones discriminadas para el estado de las solicitudes
Una solicitud pasa por fases que contienen datos diferentes. Una unión discriminada le da a cada fase su propia estructura, unida por un campo literal status:
type RequestState =
| {
status: "idle";
}
| {
status: "loading";
}
| {
status: "success";
data: User[];
}
| {
status: "error";
error: string;
};
Al activar el discriminante se reduce el tipo en cada rama:
function render(state: RequestState) {
switch (state.status) {
case "idle":
return "Nothing started";
case "loading":
return "Loading...";
case "success":
return state.data;
case "error":
return state.error;
}
}
El compilador registra qué campos existen después de cada verificación:
status = "success"
↓
data exists
status = "error"
↓
error exists
Compare el modelo común de booleanos y opcionales:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Nada impide que describa un estado que nunca debería ocurrir:
{
loading: true,
data: [...],
error: "Something failed"
}
El sindicato hace que tales combinaciones sean muy difíciles de expresar. Modela los estados válidos en lugar de dispersar propiedades opcionales y confiar en que todos las combinen correctamente.
Tipos de resultados para fallos explícitos
Muchas operaciones tienen exactamente dos resultados:
Success
OR
Failure
El tipo Result se escribe completamente con un discriminante booleano:
type Result<T, E> =
| {
success: true;
data: T;
}
| {
success: false;
error: E;
};
Una función que lo devuelve:
function getUser(): Result<User, string> {
return {
success: true,
data: user
};
}
El llamante debe verificar success antes de acceder a data o error:
const result = getUser();
if (result.success) {
console.log(result.data);
} else {
console.error(result.error);
}
Service
│
▼
Result<T, E>
/ \
/ \
▼ ▼
Success Failure
│ │
data error
Esto se aplica a los fallos comerciales esperados, como “el correo electrónico ya está registrado”. Las excepciones siguen siendo adecuadas para errores reales y fallos en la infraestructura; el tipo Resultado simplemente hace que los fallos previstos sean visibles en la firma.
Genéricos y otras herramientas a nivel de tipo
Los genéricos preservan el vínculo entre entrada y salida
Usar any descarta la información:
function identity(value: any): any {
return value;
}
Un parámetro de tipo lo mantiene:
function identity<T>(value: T): T {
return value;
}
El resultado deducido sigue el razonamiento:
const a = identity("hello");
// string
const b = identity(100);
// number
La relación entre la entrada y la salida persiste:
Input T
│
▼
Function<T>
│
▼
Output T
Los genéricos también permiten que los contratos compartidos sean reutilizables. Un sobre de respuesta:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
describe muchos payloads:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Restricciones: se requiere una forma
Esto no se compila:
function getId<T>(item: T) {
return item.id;
}
porque nada le indica a TypeScript que T tiene un id. Una restricción añade esa garantía:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Se acepta cualquier objeto con una cadena id; también se incluyen campos adicionales:
getId({
id: "123",
name: "Hareesh"
});
Lea la restricción de esta manera:
T can be anything
BUT
T must have id: string
keyof y acceso indexado
Dada una interfaz:
interface User {
id: string;
name: string;
age: number;
}
keyof genera la unión de los nombres de sus propiedades:
type UserKey = keyof User;
"id" | "name" | "age"
Al combinar keyof con un segundo parámetro de tipo y el tipo de acceso indexado T[K], se obtiene un accesorio cuyo tipo de retorno coincide con la clave:
function getProperty<T, K extends keyof T>(
object: T,
key: K
): T[K] {
return object[key];
}
const user = {
id: "1",
name: "Hareesh",
age: 30
};
getProperty(user, "name");
Una clave real compila:
getProperty(user, "name");
Una clave faltante es un error de compilación:
getProperty(user, "salary");
La fuerza proviene de tres herramientas que trabajan juntas:
Generics
+
keyof
+
Indexed Access
Tipos mapeados: transformar un tipo existente
Los tipos mapeados iteran sobre las claves de un tipo para crear uno nuevo. Comenzando por:
interface User {
id: string;
name: string;
email: string;
}
puedes obtener una versión con todas las opciones opcionales:
type OptionalUser = {
[K in keyof User]?: User[K];
};
conceptualmente igual a escribir:
{
id?: string;
name?: string;
email?: string;
}
Así es como se definen las funciones integradas como Partial.
Tipos condicionales: decisiones a nivel de tipo
Los tipos condicionales eligen entre dos tipos en función de la posibilidad de asignación:
T extends U ? X : Y
Este desempaca los tipos de elementos de array y deja todo lo demás sin cambios:
type Flatten<T> =
T extends Array<infer U>
? U
: T;
type A = Flatten<string[]>;
// string
type B = Flatten<number>;
// number
En este punto, el sistema de tipos se comporta como un pequeño lenguaje en tiempo de compilación, lo que exige prudencia.
infer: extraer parte de un tipo
Dentro de un tipo condicional, infer declara una variable de tipo que TypeScript completa mediante coincidencias. Esto vuelve a implementar el ReturnType integrado:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Úsalo junto con typeof para obtener un tipo a partir de una función existente, de modo que nunca se desvíe de su implementación:
function getUser() {
return {
id: "1",
name: "Hareesh"
};
}
type User = MyReturnType<typeof getUser>;
Las definiciones de tipos de biblioteca dependen en gran medida de esto.
Primero, los tipos de utilidad integrados
Antes de crear ayudantes inteligentes, conozca qué viene incluido con el lenguaje:
Partial
Required
Readonly
Pick
Omit
Record
Exclude
Extract
NonNullable
ReturnType
Parameters
InstanceType
Awaited
Eliminar un campo sensible, por ejemplo, consiste en una sola línea en lugar de una interfaz duplicada que se desincronizaría:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
La guía sobre los tipos de utilidad integrados de TypeScript aborda cada uno de ellos en profundidad.
Record con un conjunto cerrado de claves
Record es adecuado para búsquedas y configuraciones, especialmente cuando las claves provienen de una unión literal:
type Permission =
"read" |
"write" |
"delete";
type PermissionMap =
Record<Permission, boolean>;
const permissions: PermissionMap = {
read: true,
write: false,
delete: false
};
Si se omite un permiso, TypeScript informa sobre la clave faltante. Un tipo de clave amplio pierde esa garantía:
const permissions: Record<string, boolean>
debido a que Record<string, boolean> acepta prácticamente cualquier clave de tipo cadena, las omisiones y los errores tipográficos pasan desapercibidos.
Seguridad de tipo para identificadores y datos no confiables
Tipos especializados para identificadores de dominio
Dos identificadores pueden ser cadenas pero significar cosas diferentes:
const userId: string;
const productId: string;
Desde el punto de vista estructural, TypeScript no puede distinguirlos. Al interseccionar string con una propiedad fantasma se crean tipos distintos:
type UserId =
string & {
readonly __brand: "UserId";
};
type ProductId =
string & {
readonly __brand: "ProductId";
};
Las funciones pueden solicitar entonces el tipo correcto de ID:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Indicar un ProductId cuando se espera un UserId genera un error de compilación. La marca nunca existe en tiempo de ejecución; se crean valores relacionados con la marca mediante un pequeño constructor o función de validación que realiza la conversión en un único lugar. Conceptualmente:
string
│
├── UserId
├── ProductId
├── OrderId
└── TransactionId
Esto resulta útil en sistemas grandes donde docenas de identificadores comparten un mismo tipo primitivo.
Protectores de tipo para entrada unknown
Los datos provenientes de la red, el almacenamiento o la entrada del usuario deben ingresar como unknown:
const data: unknown = await response.json();
Un elenco es el atajo tentador:
const user = data as User;
En su lugar, reduzca el rango de valores con un guardián de tipo definido por el usuario; su tipo de retorno value is User indica al compilador qué demuestra un resultado true:
function isUser(
value: unknown
): value is User {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"name" in value
);
}
if (isUser(data)) {
console.log(data.name);
}
Recuerde que los tipos se eliminan en tiempo de ejecución. Una interfaz como esta:
interface User {
id: string;
}
No valida nada de lo que envía la API. La validación anterior solo verifica que existan las claves, no sus tipos; por lo tanto, para entradas no confiables, combine TypeScript con un validador de esquema en tiempo de ejecución.
Verificación exhaustiva con never
Una de las combinaciones más efectivas del lenguaje:
Discriminated Union
+
never
+
switch
Tome una unión de estado:
type Status =
| "loading"
| "success"
| "error";
y un ayudante que solo acepta never:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
Cuando se procesa cada miembro, la rama predeterminada muestra el tipo never, por lo que la llamada se compila:
function render(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
Ahora un compañero de equipo extiende la unión:
Now imagine someone adds:"cancelled" to Status.
La rama por defecto recibe "cancelled", que no puede asignarse a never, y la compilación falla hasta que se maneja ese caso. El compilador actúa como un revisor de diseño que recuerda a cada consumidor.
Tipos de literales de plantilla
TypeScript puede componer tipos de literales de cadena a partir de otros tipos:
type Entity =
"user" |
"order" |
"product";
type Event =
`${Entity}:created` |
`${Entity}:updated` |
`${Entity}:deleted`;
La unión resultante contiene todas las combinaciones:
user:created
user:updated
user:deleted
order:created
order:updated
order:deleted
product:created
product:updated
product:deleted
Los usos prácticos incluyen:
- nombres de eventos
- claves de eventos de análisis
- cadenas de permisos
- patrones de rutas
- nombres de flags de funcionalidades
- tokens del sistema de diseño
as const cuando los valores son la fuente de verdad
Un literal de array de cadenas se expande:
const roles = [
"admin",
"editor",
"viewer"
];
Su tipo es string[]. Al agregar as const se obtiene una tupla de literales de solo lectura:
const roles = [
"admin",
"editor",
"viewer"
] as const;
desde el cual sigue directamente un tipo de unión:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Defina los valores una vez y nunca mantenga manualmente una unión paralela.
satisfies para la configuración verificada
satisfies valida una expresión con respecto a un tipo sin ampliarla a dicho tipo:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
El objeto se verifica en relación con Config, pero config.environment mantiene el tipo literal "production", algo que una anotación simple perdería. Es adecuado para:
- configuración de rutas
- banderas de funcionalidad
- tokens de diseño
- objetos de configuración estática
- mapas de permisos
Inyección de dependencias sin framework
Crear dependencias dentro de una clase la une a ellas:
class UserService {
private api = new ApiClient();
}
Recibirlos a través del constructor mantiene a la clase agnóstica:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
La producción se envía al cliente real:
const service =
new UserService(apiClient);
y prueba un mock:
const service =
new UserService(mockApiClient);
UserService
▲
│
Dependency
│
┌────────┴────────┐
▼ ▼
ApiClient MockApiClient
Production Testing
Es una de las formas más económicas de obtener código probable, y los parámetros del constructor son todo lo que se necesita.
Diferenciar patrones similares
Patrón de estado o unión discriminada?
Dado el ciclo de vida de un pedido:
Draft
Paid
Shipped
Cancelled
Un sindicato discriminado podría ser todo lo que necesitas:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Pero cuando cada estado presenta un comportamiento significativo:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
Un patrón de estado, en el que cada objeto de estado implementa las operaciones permitidas, podría ser más adecuado. Decida según el grado de complejidad que tenga cada estado, y no según la terminología.
Estrategia o Estado?
Un tema frecuente en las entrevistas. Con el patrón de estrategia, el cliente elige el algoritmo:
Order
│
▼
Strategy
├── CreditCard
├── PayPal
└── UPI
Con State, el objeto cambia su propio comportamiento a medida que avanza por su ciclo de vida:
Order
│
├── Draft
├── Paid
└── Shipped
La estrategia determina cómo se realiza una tarea; el estado determina qué hace un objeto en su etapa actual.
Fábrica o estrategia?
Una fábrica responde a "¿qué objeto debe crearse?"
PaymentFactory.create("stripe");
Una estrategia responde a “¿qué comportamiento debe ejecutarse?”:
new Order(discountStrategy);
Se combinan de forma natural: una fábrica que produce la estrategia que realiza el trabajo:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Aplicación de patrones en React
Propiedades variant en lugar de flags booleanos
Los booleanos independientes permiten situaciones absurdas, como un botón que sea al mismo tiempo principal y de peligro:
interface ButtonProps {
primary?: boolean;
danger?: boolean;
loading?: boolean;
}
Una unión de variantes permite que cada una declare sus propios requisitos:
type ButtonProps =
| {
variant: "primary";
loading?: boolean;
}
| {
variant: "danger";
confirmationRequired: boolean;
};
La variante de peligro ahora debe especificar confirmationRequired.
APIs de componentes que rechazan contradicciones
Un componente que muestra un enlace o un botón con href y onClick opcionales permitiría usar ambos o ninguno. El fragmento a continuación muestra la versión laxa, la alternativa diferenciada y un uso válido:
{
href?: string;
onClick?: () => void;
}
use:
type ActionProps =
| {
type: "link";
href: string;
}
| {
type: "button";
onClick: () => void;
};
Now:
<Action
type="link"
href="/users"
/>
Se rechaza una variante de enlace que recibe un manejador de clic en lugar de un href:
<Action
type="link"
onClick={...}
/>
Los modos compatibles, explicados de forma sencilla:
Link
Button
TypeScript ahora forma parte de la arquitectura de componentes en lugar de ser una documentación que se vuelve obsoleta.
Un bus de eventos seguro desde el punto de vista tipológico
Comience con un mapa que relacione los nombres de los eventos con los tipos de datos a transmitir:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Un diccionario genérico sobre ese mapa relaciona cada nombre con su contenido a través de keyof y T[K]:
class EventBus<T extends Record<string, unknown>> {
on<K extends keyof T>(
event: K,
handler: (data: T[K]) => void
) {
// implementation
}
emit<K extends keyof T>(
event: K,
data: T[K]
) {
// implementation
}
}
La carga útil correcta se compila:
bus.emit("user:created", user);
El incorrecto no hace lo siguiente:
bus.emit("user:created", order);
Varios herramientas se encuentran aquí:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Tipos en toda la capa de API
Un frontend maduro suele estructurar el acceso a los datos de esta manera:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
con los tipos mantenidos en cada paso. Un repositorio puede aceptar identificadores de marca y devolver un Result:
type ApiResponse<T> = {
data: T;
status: number;
};
interface UserRepository {
getUser(id: UserId):
Promise<Result<User, ApiError>>;
}
El componente ya no se ocupa de esto en cada capa:
any
Las propias firmas indican qué puede tener éxito, qué puede fallar y con qué tipos.
Antipatrones a evitar
any en todas partes
function process(data: any) {}
Un parámetro any desactiva la verificación de todo lo que afecta. Prefiera:
function process(data: unknown) {}
Y estrechado antes de usarlo.
Afirmaciones como validación
const user =
response.data as User;
Un casteo as no verifica nada; solo le pide al compilador que confíe en usted. Valide los datos no confiables en tiempo de ejecución.
Genéricos por sí mismos
Evite firmas como:
function transform<
T,
U,
V,
R
>(...) {}
a menos que cada parámetro de tipo exprese una relación real. Un parámetro de tipo utilizado una sola vez suele ser innecesario.
Uniones enormes
Una unión discriminada con cien miembros es difícil de navegar y evolucionar. El dominio podría necesitar una abstracción diferente, como uniones anidadas o trasladar la variación a los datos.
Ingenuidad a nivel de tipo
Cuando un tipo resulta más complicado de seguir que la lógica de negocio que describe, retroceda:
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
La seguridad de tipos tiene un costo. Busque la mayor seguridad útil por unidad de complejidad, no los tipos más sofisticados.
Un árbol de decisiones para elegir
Este árbol relaciona las presiones comunes con los patrones candidatos:
PROBLEM
│
├── Need to create objects?
│ ├── Simple creation → Constructor
│ ├── Complex creation → Builder
│ ├── Multiple implementations → Factory
│ └── Families of objects → Abstract Factory
│
├── Need to integrate another system?
│ └── Adapter
│
├── Complex subsystem?
│ └── Facade
│
├── Add behavior without modifying object?
│ └── Decorator
│
├── Multiple interchangeable algorithms?
│ └── Strategy
│
├── Subscribers react to changes?
│ └── Observer
│
├── Need actions/history/undo?
│ └── Command
│
├── Data-access abstraction?
│ └── Repository
│
├── Complex state lifecycle?
│ └── State / Discriminated Union
│
└── Type-level problem?
├── Reuse → Generics
├── Transform → Mapped Types
├── Decision → Conditional Types
├── Extract → infer
├── Property safety → keyof
├── Literal safety → as const
├── Contract validation → satisfies
└── Domain safety → Branded Types
Considérelo como un punto de partida para la discusión; el principio de “empezar simple” sigue siendo aplicable en cada caso.
Qué aprender primero
Para los ingenieros front-end que se preparan para roles de nivel senior o líder, memorizar cada patrón del Gang of Four es un mal uso del tiempo. Un orden práctico:
Lo esencial:
Discriminated Unions
Generics
Type Guards
keyof
Mapped Types
Utility Types
Composition
Strategy
Repository
Factory
Se recomienda encarecidamente lo siguiente:
Result Type
Branded Types
Conditional Types
infer
Exhaustive Checking
Dependency Injection
Adapter
Facade
Observer
Decorator
Vale la pena entenderlo conceptualmente:
Builder
Command
State
Abstract Factory
Singleton
El trabajo diario en el frontend se basa en gran medida en esta combinación:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
mejor que en cualquier libro de texto sobre fábricas abstractas.
Cómo cambia el juicio con la experiencia
Al principio, los ingenieros se preguntan “¿qué patrón debo usar aquí?”. Con experiencia en sistemas grandes, la pregunta pasa a ser “¿cuál es la abstracción más simple que resuelve este problema sin hacer que el sistema sea más difícil de entender?”. El flujo de trabajo que prioriza los patrones se ve así:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
El flujo de trabajo centrado en el problema se ve así:
Problem
↓
Understand volatility
↓
Identify boundary
↓
Start simple
↓
Introduce abstraction only where repetition/change justifies it
Los buenos patrones surgen a medida que se hacen evidentes las presiones de la arquitectura; no se imponen de antemano.
Preguntas de entrevista que evalúan la verdadera comprensión
“¿Qué es el patrón Factory?” prueba la memorización. Estas prueban el juicio.
Arquitectura:
- Ante un componente React de 2,000 líneas, ¿cómo elige qué abstracción introducir?
- ¿Cuándo rechazaría un patrón que parece adecuado?
- ¿Cómo puede saber si una abstracción es prematura?
- ¿Cuándo es preferible la composición sobre la herencia?
- ¿Cómo evita que un Singleton se convierta en un estado mutable global?
Conceptos básicos de TypeScript:
- ¿Cómo modelaría una solicitud con estados de carga, éxito, error y reintentos?
- ¿Cómo evita las combinaciones inválidas de propiedades de React?
- ¿En qué se diferencian
unknown,anyynever? - ¿Cuándo elegiría
typeen lugar deinterface, y viceversa? - ¿Cómo interactúa
keyofcon los genericos?
TypeScript avanzado:
- ¿Cuál es un caso de uso concreto para los tipos condicionales?
- ¿Qué hace
infer? - ¿Qué es un tipo mapeado?
- ¿Qué son los tipos condicionales distributivos?
- ¿Cuándo valen la pena los tipos marcados?
- ¿Qué problema resuelve
satisfies? - ¿Cómo afecta
as consta la inferencia?
Diseño en el mundo real:
- Diseñar un bus de eventos seguro desde el punto de vista tipológico.
- Diseñar un cliente de API seguro desde el punto de vista tipológico.
- Diseñar un sistema de permisos utilizando TypeScript.
- Diseñar una abstracción de pagos que soporte Stripe, PayPal y un tercer proveedor.
- ¿Cómo agregarías una capa de Repository a una aplicación React existente sin tener que reescribirla?
- ¿Cómo escribirías eventos WebSocket con cargas diferentes?
- ¿Cómo evitarías que se pasara un
ProductIddonde se requiere unUserId?
Una pregunta reveladora: “Describe una ocasión en la que elegiste deliberadamente no usar un patrón de diseño”. Una respuesta sólida explica el equilibrio: al no haber más que una implementación y sin posibles cambios que prevenir, la indirección no habría reducido la complejidad, por lo que el código se mantuvo simple hasta que otro caso de uso lo justificó.
Un modelo mental final
Comienza con el problema empresarial, identifica dónde reside la complejidad, separa los cambios de comportamiento de los cambios estructurales, y luego deja que la seguridad de tipos refuerce el resultado.
BUSINESS PROBLEM
│
▼
Identify Complexity
│
▼
What is likely to change?
│
┌──────────┴──────────┐
▼ ▼
Behavior Structure
│ │
Strategy/State Adapter/Facade
│ │
└──────────┬──────────┘
▼
Type Safety
│
┌───────────────┼────────────────┐
▼ ▼ ▼
Generics Unions Utilities
│ │ │
▼ ▼ ▼
keyof Result Type Mapped Types
infer State Model Conditional
satisfies Exhaustive Record
│ │ │
└───────────────┼────────────────┘
▼
SIMPLEER CODE
Patrones como vocabulario compartido
Los patrones funcionan mejor como vocabulario compartido: “esto es un adaptador”, “estos comportamientos son intercambiables”, “estos estados pertenecen a una unión”, “etiquetar estos IDs” y, lo más importante, “esta abstracción aún no ha merecido su complejidad”. Un buen TypeScript se evalúa según si el sistema posee estas características, y no por lo ingeniosos que sean sus tipos:
Invalid states
↓
become difficult to represent
Changing implementations
↓
don't break consumers
Business rules
↓
are visible in the types
Shared behavior
↓
is reusable without duplication
Complexity
↓
is isolated behind clear boundaries
- Elija los patrones según la complejidad que eliminan, no por su familiaridad.
- Preferir las uniones, los tipos
Resulty las verificaciones exhaustivas a los campos opcionales y la esperanza. - Valide los datos en los límites del tiempo de ejecución; los tipos por sí solos no lo protegen allí.
- Utilice la abstracción más simple que maneje el cambio que realmente espera.
Leer más
- Dirigir agentes de codificación AI con cuatro patrones de arquitectura de React con nombre: Aprenda cuatro patrones de React (composición de secciones, ganchos personalizados, componentes polimórficos, reductores) y cómo nombrarlos en las instrucciones le permite obtener código generado por IA más limpio.
- Patrones de Diseño de React: De OOP Clásico a Ganchoes Modernos: Explica cómo los patrones de software clásicos como Singleton, Factory y Observer se aplican en React, junto con patrones específicos de React como HOCs, Ganchoes y Componentes Compuestos.