La diferencia entre un equipo que usa TypeScript como "JavaScript con tipos opcionales" y un equipo que realmente aprovecha el sistema de tipos es enorme en términos de mantenibilidad, confiabilidad y velocidad de desarrollo. Estos son los patrones que usamos en Alternetica y que deberían estar en el repertorio de todo desarrollador TypeScript en 2025.
El operador satisfies: precisión sin pérdida de tipo
Introducido en TypeScript 4.9, satisfies valida que un valor satisface un tipo sin cambiar el tipo inferido del valor. Parece sutil, pero la diferencia es significativa.
// El problema con anotación directa
type Config = Record<string, string | number>
const config: Config = {
host: "localhost",
port: 5432,
name: "mi_db"
}
// config.port es string | number, perdemos la precisión
config.port.toFixed(2) // Error: string no tiene toFixed
// Con satisfies: validación sin pérdida de tipo inferido
const config2 = {
host: "localhost",
port: 5432,
name: "mi_db"
} satisfies Config
// config2.port es number (inferido correctamente)
config2.port.toFixed(2) // Funciona
satisfies es especialmente útil para objetos de configuración, mapas de rutas y cualquier estructura donde quieres validar contra un tipo pero mantener la precisión del tipo inferido.
Template Literal Types para APIs tipadas
Los template literal types permiten construir tipos de string con precisión quirúrgica.
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
type ApiVersion = 'v1' | 'v2'
type ApiEndpoint = `/${ApiVersion}/${string}`
// Tipos de eventos tipados para un sistema de notificaciones
type EntityType = 'usuario' | 'orden' | 'producto' | 'factura'
type EventAction = 'creado' | 'actualizado' | 'eliminado' | 'procesado'
type EventType = `${EntityType}:${EventAction}`
// Ahora EventType es exactamente la unión de todas las combinaciones
const evento: EventType = 'orden:creado' // OK
const evento2: EventType = 'orden:inventado' // Error
// Útil para sistemas de permisos
type Resource = 'usuarios' | 'ordenes' | 'reportes'
type Permission = `${Resource}:read` | `${Resource}:write` | `${Resource}:delete`
function checkPermission(user: User, permission: Permission): boolean {
return user.permissions.includes(permission)
}
Tipos condicionales para lógica de tipos reutilizable
Los tipos condicionales son los "if/else" del sistema de tipos.
// Extraer el tipo del array si es array, o el tipo mismo si no lo es
type Unwrap<T> = T extends (infer U)[] ? U : T
type A = Unwrap<string[]> // string
type B = Unwrap<string> // string
type C = Unwrap<User[]> // User
// Hacer todas las propiedades profundamente opcionales (DeepPartial)
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T
// Extraer el tipo de retorno de una promesa
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
// Obtener solo las claves cuyo valor es de cierto tipo
type KeysOfType<T, V> = {
[K in keyof T]: T[K] extends V ? K : never
}[keyof T]
interface Usuario {
id: number
nombre: string
email: string
activo: boolean
credito: number
}
type StringKeys = KeysOfType<Usuario, string>
// = 'nombre' | 'email'
La palabra clave infer: inferencia dentro de tipos condicionales
infer es la herramienta para extraer tipos de estructuras complejas.
// Extraer parámetros de una función
type Parameters<T extends (...args: any) => any> =
T extends (...args: infer P) => any ? P : never
// Extraer el tipo de retorno
type ReturnType<T extends (...args: any) => any> =
T extends (...args: any) => infer R ? R : never
// Caso práctico: extraer el tipo de datos de una respuesta de API
type ApiResponse<T> = {
data: T
status: number
message: string
}
type ExtractData<T> = T extends ApiResponse<infer D> ? D : never
type UsuarioResponse = ApiResponse<Usuario>
type UsuarioData = ExtractData<UsuarioResponse> // = Usuario
Branded Types para modelado de dominio
Los branded types previenen la mezcla accidental de tipos que tienen la misma estructura pero significados diferentes.
// Sin branded types: estos tipos son intercambiables (mal)
type UserId = string
type OrdenId = string
function getOrden(userId: UserId, ordenId: OrdenId) { /* ... */ }
// Nadie te impide llamar: getOrden(ordenId, userId) -- bug silencioso
// Con branded types: TypeScript detecta el error
declare const _brand: unique symbol
type Brand<T, B> = T & { readonly [_brand]: B }
type UserId = Brand<string, 'UserId'>
type OrdenId = Brand<string, 'OrdenId'>
type ProductoId = Brand<number, 'ProductoId'>
function createUserId(id: string): UserId {
return id as UserId
}
function getOrden(userId: UserId, ordenId: OrdenId) { /* ... */ }
const uid = createUserId('user-123')
const oid = 'ord-456' as OrdenId
getOrden(oid, uid) // Error de TypeScript
getOrden(uid, oid) // OK
Los branded types son especialmente valiosos en sistemas con múltiples tipos de IDs o unidades de medida (litros vs kilogramos, COP vs USD).
Discriminated Unions para modelado de estados
El patrón de discriminated unions hace imposible acceder a datos que no existen en el estado actual.
// Sin discriminated union: el tipo no modela la realidad
interface Solicitud {
estado: 'pendiente' | 'aprobada' | 'rechazada'
motivo_rechazo?: string // Solo existe si rechazada
aprobado_por?: string // Solo existe si aprobada
}
// Con discriminated union: cada estado tiene exactamente los campos correctos
type Solicitud =
| { estado: 'pendiente'; fecha_solicitud: Date }
| { estado: 'aprobada'; aprobado_por: string; fecha_aprobacion: Date }
| { estado: 'rechazada'; motivo: string; rechazado_por: string }
function procesarSolicitud(solicitud: Solicitud) {
switch (solicitud.estado) {
case 'pendiente':
// TypeScript sabe que solo tiene fecha_solicitud
console.log(solicitud.fecha_solicitud)
break
case 'aprobada':
// Solo puede acceder a aprobado_por y fecha_aprobacion
console.log(solicitud.aprobado_por)
break
case 'rechazada':
console.log(solicitud.motivo)
break
}
}
Zod para validación en runtime
TypeScript desaparece en runtime. Zod añade validación en runtime con inferencia de tipos automática.
import { z } from 'zod'
const UsuarioSchema = z.object({
id: z.string().uuid(),
nombre: z.string().min(2).max(100),
email: z.string().email(),
rol: z.enum(['admin', 'editor', 'viewer']),
edad: z.number().min(18).optional(),
creado_en: z.coerce.date()
})
// Tipo inferido automáticamente, nunca desincronizado
type Usuario = z.infer<typeof UsuarioSchema>
// Validación en runtime (ej: datos de API externa)
const resultado = UsuarioSchema.safeParse(datosDesconocidos)
if (resultado.success) {
// resultado.data es de tipo Usuario con full type safety
console.log(resultado.data.email)
} else {
// resultado.error contiene errores detallados
console.error(resultado.error.format())
}
Errores comunes de desarrolladores junior
1. Usar any como escape hatch: any desactiva el sistema de tipos. Usa unknown con type guards en su lugar.
2. Assertions innecesarias con as: valor as Tipo es una mentira al compilador. Solo úsalo cuando realmente sabes más que TypeScript.
3. Interfaces vacías para extender: interface MiTipo extends {} {} es un antipatrón. Usa type MiTipo = {} o modela el tipo correctamente.
4. No aprovechar los tipos de utilidad estándar: Partial<T>, Required<T>, Pick<T, K>, Omit<T, K>, Record<K, V>, Readonly<T>. Conocerlos ahorra horas.
5. Tipos excesivamente amplios en parámetros de función: function procesar(datos: object) vs function procesar(datos: { id: string; nombre: string }). La precisión en los parámetros hace el código auto-documentado.
Conclusión: el sistema de tipos como documentación viva
Un sistema de tipos bien diseñado en TypeScript hace que el código se auto-documente y que los errores sean imposibles de cometer en lugar de solo detectables. La inversión en aprender estos patrones se recupera rápidamente en menor tiempo de debugging y mayor confianza al refactorizar.
En Alternetica todos nuestros proyectos usan TypeScript en modo estricto desde el día uno. Si tu equipo está adoptando TypeScript o quieres hacer code review de una arquitectura existente, contáctanos.