Elegir patrones de TypeScript según la complejidad que eliminan
Un recorrido por los patrones de diseño clásicos y las técnicas de TypeScript a nivel de tipos, con indicaciones claras sobre cuándo cada uno tiene su lugar y cuándo el código simple es la mejor opción.
La mayoría de los equipos adoptan TypeScript para el autocompletado y la detección de errores tipográficos, y luego descubren su verdadero valor: permite codificar decisiones de diseño para que el compilador las aplique. 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 requiere unUserId? - ¿Se pueden asignar a un componente propiedades que se contradigan 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 reconoces cuando aparece un problema recurrente.
Cartografía del panorama
La clasificación clásica incluye patrones creacionales, estructurales y de comportamiento; TypeScript añade 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 solo patrón. Un flujo de datos típico en React combina varios de ellos, y cada límite puede tener tipos precisos:
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 está permitido que el sistema se comporte.
Comienza con el problema, no con el patrón
Una trampa común es razonar en la dirección equivocada:
"I know Factory Pattern.
Where can I use Factory?"
Elegir un patrón primero genera abstracciones que nadie necesitaba. 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 sobre el método de pago:
if (paymentMethod === "card") {
// ...
}
if (paymentMethod === "paypal") {
// ...
}
if (paymentMethod === "upi") {
// ...
}
La respuesta instintiva suele ser esta:
Don’t immediately think:
“Esto necesita Strategy”. Antes de recurrir a ella, pregúntese si las ramas son realmente algoritmos intercambiables bajo un mismo contrato. Si lo son, Strategy es la opción adecuada. Si el problema real es que algunos campos solo tienen sentido para ciertos métodos, una unión discriminada que impida representar combinaciones inválidas podría ser la herramienta más adecuada. Conocer el catálogo es fácil; lo importante es saber cómo asociar una entrada a una situació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 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 que lo solicitan obtienen el objeto compartido en lugar de crear uno nuevo:
const logger = Logger.getInstance();
logger.log("Application started");
Cada consumidor termina apuntando al mismo objeto:
Logger
│
getInstance()
│
▼
┌───────────┐
│ Logger │
│ Instance │
└───────────┘
▲ ▲
│ │
Service A Service B
Algunos candidatos razonables incluyen:
- registro de logs
- gestores de análisis
- guardianes de configuración
- algunos gestores de conexiones
- otros servicios de infraestructura transversal
El problema es que un Singleton es en realidad un acceso global disfrazado: los tests se vuelven más difíciles de aislar, las dependencias desaparecen de las firmas de funciones, el ciclo de vida se vuelve confuso y el estado mutable compartido cambia de maneras 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 un esquema visible.
Fábrica: ocultar qué clase se crea
Una fábrica toma la decisión sobre qué clase concreta instanciar lejos del consumidor. Compare la construcción directa:
const payment = new StripePayment();
con la 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 a la clase adecuada. 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 es un tenedor identificado por el nombre del proveedor:
PaymentFactory
│
┌────────────┴────────────┐
│ │
"stripe" "paypal"
│ │
▼ ▼
StripePayment PayPalPayment
Una fábrica resulta útil cuando:
- la construcción implica 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
Para construcciones triviales como la de abajo, solo añade una capa adicional que hay que analizar:
new User();
Fábrica abstracta: familias de objetos relacionados
La fábrica abstracta extiende esta idea a grupos de objetos que deben ser compatibles entre sí. En un kit de interfaz gráfica dirigido a varias plataformas, un botón web nunca debe combinarse con un modal móvil; por eso 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 frontales, la composición simple de componentes permite lograr el mismo resultado con menos formalismos.
Constructor: construcción controlada paso a paso
El constructor resulta útil cuando un objeto tiene muchas configuraciones opcionales o se configura en etapas. Cada método de asignació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();
La sintaxis fluida es un efecto secundario, no el objetivo principal. Lo importante es mantener la construcción complicada de forma explícita y en un único lugar, donde build() también puede validarla, por ejemplo rechazando un cuerpo en una solicitud GET.
Patrones estructurales
Adaptador: un contrato estable alrededor de la API de otro
El adaptador 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 otro nombre:
class LegacyPaymentSDK {
makePayment(value: number) {
// third-party implementation
}
}
Un adaptador sencillo implementa su 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 controla:
PaymentGateway
en lugar de depender de cada proveedor involucrado:
Stripe
PayPal
LegacySDK
SomeFutureProvider
Soportar a un nuevo proveedor implica escribir otro adaptador.
Fachada: una llamada para un flujo de trabajo multietapa
Una fachada proporciona un punto de entrada simple frente a un subsistema complejo. El proceso de inicio de sesión podría involucrar varios servicios:
Authentication
+
User Service
+
Permissions
+
Notification
+
Analytics
Sin una fachada, cada componente que gestiona el inicio de sesión debe orquestar la secuencia por sí mismo:
auth.login();
user.load();
permission.load();
analytics.track();
Una fachada se encarga de orquestar todo eso 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 interfaz. El contrato:
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 una marca 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}`
);
}
}
La envoltura es simplemente un proceso de construcción:
const logger = new TimestampLogger(
new ConsoleLogger()
);
Dado que cada decorador es a su vez un Logger, se apilan:
Logger
│
▼
ConsoleLogger
│
▼
TimestampLogger
│
▼
AdditionalDecorator
La misma idea aparece bajo otros nombres:
- cadenas de middleware
- funciones envolventes
- Componentes de orden superior en React
- registro de logs
- capas de caché
- verificaciones de autorización
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 la estrategia, 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
Agregar un nuevo nivel generalmente significa añadir una estrategia en lugar de editar la lógica existente, lo cual representa el Principio de Apertura/Cierre en la práctica. Para tres reglas muy simples, la solución condicional es adecuada; la estrategia resulta útil cuando las reglas desarrollan sus propias dependencias o pruebas.
Observador: notificaciones uno-a-muchos
El observador permite que un sujeto notifique a cualquier número 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 se encarga de la gestión del ciclo de vida. Olvidarse de llamarla conlleva:
- fugas de memoria debido a oyentes que sobreviven a sus propietarios
- manejo duplicado cuando un oyente 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
El comando 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 se necesita:
- un historial de lo que ha ocurrido
- funciones de deshacer y rehacer
El verdadero deshacer suele requerir que cada comando se invierta a sí mismo, por lo que las versiones de producción suelen añadir 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 de un medio de transporte:
fetch()
axios()
graphqlClient()
Esto es importante cuando cambia el código fuente: 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 se encargue de todo:
MegaComponent
├── Authentication
├── Table
├── Filters
├── Modal
├── Notifications
├── API calls
└── Business logic
divida las responsabilidades en partes específicas:
Dashboard
├── Header
├── Sidebar
├── FilterPanel
├── DataTable
└── NotificationPanel
En React esto se logra simplemente anidando componentes:
<Dashboard>
<Header />
<Sidebar />
<MainContent />
</Dashboard>
Cada parte puede ser comprendida, probada y reemplazada por separado. Para más información sobre cómo dividir componentes demasiado grandes, 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í es la herramienta arquitectónica.
Uniones discriminadas para el estado de la solicitud
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 utilizar el discriminante, se restringe 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 valores opcionales:
interface State {
loading: boolean;
data?: User[];
error?: string;
}
Nada impide describir un estado que nunca debería ocurrir:
{
loading: true,
data: [...],
error: "Something failed"
}
La unión hace que tales combinaciones sean muy difíciles de expresar. Modelice los estados válidos en lugar de dispersar propiedades opcionales y confiar en que todos las combinen correctamente.
Tipos de resultado para fallos explícitos
Muchas operaciones tienen exactamente dos resultados:
Success
OR
Failure
El tipo Result especifica ambos resultados mediante 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 los errores reales y las fallas de infraestructura; el tipo Result simplemente hace que los fallos previstos sean visibles en la firma.
Genéricos y herramientas a nivel de tipo
Los genéricos preservan la relación entre entrada y salida
Usar any descarta la información:
function identity(value: any): any {
return value;
}
Un parámetro de tipo la mantiene:
function identity<T>(value: T): T {
return value;
}
El resultado inferido sigue al argumento:
const a = identity("hello");
// string
const b = identity(100);
// number
La relación entre entrada y salida se mantiene:
Input T
│
▼
Function<T>
│
▼
Output T
Los genericos también hacen que los contratos compartidos sean reutilizables. Un solo sobre de respuesta:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
describe muchos payloads:
type UserResponse =
ApiResponse<User>;
type ProductResponse =
ApiResponse<Product>;
Restricciones: requerir 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 agrega esa garantía:
function getId<T extends { id: string }>(
item: T
) {
return item.id;
}
Se acepta cualquier objeto con un id de tipo cadena, incluidos los 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"
Combinar keyof con un segundo parámetro de tipo y el tipo de acceso indexado T[K] genera 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 se compila correctamente:
getProperty(user, "name");
Una clave faltante provoca un error de compilación:
getProperty(user, "salary");
La solidez proviene de la combinación de tres herramientas que trabajan juntas:
Generics
+
keyof
+
Indexed Access
Tipos mapeados: transformar un tipo existente
Los tipos mapeados recorren las claves de un tipo para crear uno nuevo. Partiendo de:
interface User {
id: string;
name: string;
email: string;
}
se puede derivar una versión con todos los campos opcionales:
type OptionalUser = {
[K in keyof User]?: User[K];
};
conceptualmente equivalente a escribir:
{
id?: string;
name?: string;
email?: string;
}
Así se definen funciones integradas como Partial.
Tipos condicionales: decisiones a nivel de tipo
Los tipos condicionales eligen entre dos tipos según su capacidad de asignación:
T extends U ? X : Y
Este desempaqueta los tipos de elementos de array y deja todo lo demás intacto:
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 moderación.
infer: extraer parte de un tipo
Dentro de un tipo condicional, infer declara una variable de tipo que TypeScript completa mediante coincidencias. Esto reimplanta el ReturnType incorporado:
type MyReturnType<T> =
T extends (...args: any[]) => infer R
? R
: never;
Úsalo junto con typeof para derivar 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 las bibliotecas dependen en gran medida de esto.
Primero, los tipos de utilidad incorporados
Antes de escribir 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, se hace en una sola línea en lugar de mediante una interfaz duplicada que causaría desincronización:
interface User {
id: string;
name: string;
email: string;
password: string;
}
type PublicUser =
Omit<User, "password">;
La guía sobre los tipos de utilidad integrados en TypeScript aborda cada uno de estos temas 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 string, las omisiones y errores tipográficos pasan desapercibidos.
Patrones de seguridad 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;
Estructuralmente, 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";
};
Luego, las funciones pueden exigir el tipo correcto de ID:
function getUser(id: UserId) {}
function getProduct(id: ProductId) {}
Passar un ProductId donde se espera un UserId genera un error de compilación. La marca nunca existe en tiempo de ejecución; se crean valores con marca a través de un pequeño constructor o función de validación que realiza el casting 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.
Guardianes 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 casting es el atajo tentador:
const user = data as User;
En su lugar, restrinja el valor con un guardián de tipo definido por el usuario; su tipo de retorno value is User le 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);
}
Tenga en cuenta 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. El guardiá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
Utilice una unión de estados:
type Status =
| "loading"
| "success"
| "error";
y una función auxiliar que solo acepte never:
function assertNever(
value: never
): never {
throw new Error(
`Unexpected value: ${value}`
);
}
Cuando se maneja cada miembro, la rama por defecto 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 se puede asignar a never, y la compilación falla hasta que se maneje 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 analíticos
- cadenas de permisos
- patrones de rutas
- nombres de flags de funcionalidad
- tokens del sistema de diseño
as const cuando los valores son la fuente de verdad
Un literal de array de cadenas se amplía:
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;
De la cual se deriva directamente un tipo de unión:
type Role =
typeof roles[number];
becomes:
"admin" |
"editor" |
"viewer"
Defina los valores una sola vez y nunca mantenga manualmente una unión paralela.
satisfies para configuración verificada
satisfies valida una expresión contra un tipo sin ampliarla a ese tipo:
type Config = {
retries: number;
environment:
| "development"
| "production";
};
const config = {
retries: 3,
environment: "production"
} satisfies Config;
El objeto se verifica contra 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
Crear dependencias dentro de una clase la une a ellas:
class UserService {
private api = new ApiClient();
}
Recibirlas a través del constructor mantiene a la clase agnóstica:
class UserService {
constructor(
private readonly api: ApiClient
) {}
}
En producción se pasa el cliente real:
const service =
new UserService(apiClient);
y en las pruebas se utiliza uno simulado:
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 solo se necesitan los parámetros del constructor.
Diferenciar patrones similares
Patrón de estado o unión discriminada?
Dado el ciclo de vida de un pedido:
Draft
Paid
Shipped
Cancelled
Una unión discriminada podría ser todo lo que necesitas:
type Order =
| { status: "draft" }
| { status: "paid" }
| { status: "shipped" }
| { status: "cancelled" };
Pero cuando cada estado implica un comportamiento considerable:
Draft
├── edit()
├── submit()
Paid
├── refund()
├── ship()
Shipped
├── track()
└── deliver()
un patrón de estado, donde cada objeto de estado implementa las operaciones permitidas, podría ser más adecuado. Decide según el nivel de complejidad que tenga cada estado, no por la terminología.
Estrategia o Estado?
Un tema frecuente en las entrevistas. Con Strategy, 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
Strategy varía la forma en que se realiza una tarea; State varía lo que hace un objeto en su etapa actual.
Fábrica o Strategy?
Una Fábrica responde “¿qué objeto debe crearse?”:
PaymentFactory.create("stripe");
Strategy responde “¿qué comportamiento debe ejecutarse?”:
new Order(discountStrategy);
Se combinan de forma natural: una fábrica produce la estrategia que realiza el trabajo:
Factory
↓
creates
↓
Strategy
↓
executes behavior
Aplicación de los 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 a la que se le proporciona un manejador de clic en lugar de href:
<Action
type="link"
onClick={...}
/>
Los modos soportados, expresados de forma clara:
Link
Button
TypeScript ahora forma parte de la arquitectura del componente 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 eventos con los tipos de carga:
type Events = {
"user:created": User;
"user:deleted": UserId;
"order:created": Order;
};
Un genérico para el bus basado en ese mapa vincula cada nombre a su carga mediante 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
}
}
El payload correcto se compila:
bus.emit("user:created", user);
El incorrecto no se compila:
bus.emit("user:created", order);
Varios herramientas son útiles aquí:
Generics
+
keyof
+
Indexed Access
+
Mapped Type thinking
Tipos en toda la capa de API
Un frontend maduro suele organizar el acceso a los datos de esta manera:
Component
↓
Hook
↓
Service
↓
Repository
↓
API Client
↓
HTTP
con tipos que se transmiten en cada paso. Un repositorio puede aceptar IDs personalizados 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 toca. Prefiera:
function process(data: unknown) {}
y restrinja el tipo antes de usarlo.
Afirmaciones como validación
const user =
response.data as User;
Un cast con as no verifica nada; solo le pide al compilador que confíe en usted. Valide los datos no fiables en tiempo de ejecución.
Genéricos por sí mismos
Evite firmas como estas:
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 es más difícil de seguir que la lógica empresarial que describe, retroceda:
Type complexity
│
▼
Developer complexity
│
▼
Maintenance cost
La seguridad de tipo 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 un punto de partida para la discusión; el principio de “empezar simple” sigue siendo aplicable en cada nodo.
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 comprenderlo conceptualmente:
Builder
Command
State
Abstract Factory
Singleton
El trabajo diario en front-end se basa mucho más en esta combinación:
Generics
+
Discriminated Unions
+
Composition
+
Strategy
+
Repository
que en cualquier patrón de Fábrica Abstracta de los libros de texto.
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 basado en los patrones se ve así:
Problem
↓
Pattern
↓
More classes
↓
More abstractions
El flujo de trabajo basado 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 cuando se clarifican 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:
- Frente a 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?
Conceptos básicos de TypeScript:
- ¿Cómo modelarías una solicitud con estados de carga, éxito, error y reintentos?
- ¿Cómo se impiden las combinaciones inválidas de propiedades en React?
- ¿En qué se diferencian
unknown,anyynever? - ¿Cuándo elegirías
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é función tiene
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 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ía una capa de Repository a una aplicación React existente sin tener que reescribirla?
- ¿Cómo tipificaría eventos WebSocket con diferentes cargas de datos?
- ¿Cómo evitaría que se pasara un
ProductIddonde se requiere unUserId?
Una pregunta reveladora: “Describa un momento en el que eligió deliberadamente no usar un patrón de diseño”. Una respuesta sólida explica el equilibrio: al tener una única implementación y sin cambios probables 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
Comience con el problema empresarial, identifique dónde reside la complejidad, separe los cambios de comportamiento de los cambios estructurales, y luego deje que la seguridad tipológica 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
Puntos clave
Los patrones funcionan mejor como vocabulario compartido: “esto es un adaptador”, “estos comportamientos son intercambiables”, “estos estados pertenecen a una unión”, “etiqueta estos IDs” y, lo más importante, “esta abstracción aún no ha merecido su complejidad”. Una buena implementación en TypeScript se evalúa por 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 eliminen, no por su familiaridad.
- Preferir las uniones, los tipos
Resulty las verificaciones exhaustivas en lugar de 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í.
- Busque la abstracción más simple que maneje el cambio que realmente espera.
Lectura relacionada
- Dirigir agentes de codificación AI con cuatro patrones de arquitectura React nombrados — Aprenda cuatro patrones de React (composición de secciones, ganchos personalizados, componentes polimórficos y reducers) y cómo nombrarlos en las instrucciones le permite obtener código generado por IA más limpio.
- Patrones de diseño React: desde el OOP clásico hasta los ganchos 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, ganchos y componentes compuestos.