Tipos guerrilla en TypeScript
Diseño tipográfico rebelde
Guerrilla Types en TypeScript
En este artículo exploraremos tres técnicas intrigantes (¿posiblemente terribles?) para ayudar en el diseño de tipos.
El objetivo principal es consistente y predecible en las interfaces de Modelo/Entidad/Clase.
- Enfoques para diseñar tipos
- Técnica #1: ¿Por qué no todos?
- Técnica #2: Mix-ins
- Técnica #3: Organización con namespaces
- Resumen
Enfoques para diseñar tipos
Probablemente hayas encontrado o escrito distintos patrones alrededor de “implementaciones de tipos”, sobre todo al consumir datos de APIs de terceros.
Nota: Ignoro intencionalmente los procesos “tradicionales” de construir diagramas de entidad‑relación (ERD) o jerarquías de herencia de programación orientada a objetos (OOP). Aquí, estamos creando tipos para representar datos de API semiestructurados.
Exploremos dos enfoques de alto nivel: Objeto grande único (de arriba hacia abajo) vs. Múltiples tipos nombrados (de abajo hacia arriba).
Objeto grande único
Prioriza ser explícito sobre la reutilización y el DRY.
Bonus: La experiencia en el IDE/Desarrollo es excelente, ya que los tooltips incluyen una vista previa más completa, sin complicaciones.
interface ProductDetails { name: string; seller: { name: string }; availability: Array<{ warehouseId: string; quantity: number }>; reviews: Array<{ authorId: number; stars: number }>;}Como estamos priorizando la legibilidad explícita, está bien permitir algo de repetición (dentro de lo razonable). Cuando los grupos de propiedades se repiten muchas veces, siéntete libre de extraer los campos repetidos a un tipo con nombre.
Múltiples tipos nombrados
Prioriza la reutilización y el DRY.
Este enfoque es probablemente el preferido por un amplio margen.
interface ProductDetails { name: string; seller: Seller; reviews: Reviews[]; availability: Availability[];}interface Seller { name: string; }interface Availability { warehouseId: string; quantity: number; }interface Reviews { authorId: number; stars: number; }En general, este enfoque es excelente. Pero no está exento de inconvenientes.
- La legibilidad es excelente al principio; sin embargo, puede deteriorarse a medida que aumenta el tamaño y la cantidad de tipos.
- Relentemente DRY, pero ¿a qué precio? (Más sobre esto más adelante.)
- La experiencia del desarrollador puede resentirse porque los tooltips son menos informativos.
⚠️ Desde (aproximadamente) TypeScript v3, el Language Server trunca los tooltips, omitiendo propiedades anidadas.
💡 Hay trucos para mejorar un poco las cosas. Mantén presionadoCmdoCtrly pasa el cursor sobre varios nombres de tipo; deberías ver al menos una capa extra de propiedades en el tooltip.
¿Por qué tenemos que elegir entre estos dos enfoques? (Tipo grande vs. Sub‑tipos nombrados.)
Técnica #1: ¿Por qué no todo?
¿Podemos tenerlo todo?
- ¿Claridad de los tipos de “gran panorama”?
- ¿Más sub‑tipos nombrados?
- ¿Sin duplicación?
✅ ¡SÍ! 🎉
export interface ProductDetails { name: string; seller: { name: string }; reviews: Array<{ authorId: number; stars: number }>; availability: Array<{ warehouseId: string; quantity: number }>;}export type Seller = ProductDetails["seller"];export type Review = ProductDetails["reviews"][number];export type Availability = ProductDetails["availability"][number];- Crear tipos estructurados “Primarios” grandes.
- Exportar sub‑tipos derivados del tipo Primario.
Este enfoque realmente brilla en sistemas donde los objetos “de alto nivel” se benefician de una documentación centralizada.
Además, la técnica permite reutilizar los tipos en numerosos casos de uso: modelos, servicios, resultados de consultas, etc.
Técnica #2: Mix‑ins
Esta estrategia se trata de combinar los campos correctos, con los nombres correctos, para representar objetos lógicos únicos. El objetivo es atender eficientemente múltiples casos de uso usando utilidades y uniones de tipos de TypeScript.
Este enfoque difiere de la herencia tradicional de OOP y de jerarquías, que buscan crear capas de objetos dentro de taxonomías fuertemente vinculadas. El enfoque mix‑in se basa en tipos planos y poco relacionados, agrupando campos relacionados mientras se reduce la duplicación.
Ejemplos de Mix‑ins
interface TodoModel { text: string; complete: boolean;}interface InstanceMixin { id: number;}/** TodoDraft representa el estado del formulario, posiblemente con todo indefinido */export type TodoDraft = Partial<TodoModel>;/** Todo representa un registro de instancia de Todo proveniente de la base de datos */export type Todo = TodoModel & InstanceMixin;Ejemplo User
interface User { id: number; name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}Representemos el User antes y después de guardarlo en la base de datos.
// Campos principales del usuario (por ejemplo para un <form>)interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Campos provenientes de la base de datosinterface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}// Una **instancia** de User - con todos los campostype UserInstance = InstanceMixin & UserBase;Ahora podemos esculpir exactamente los campos que necesitamos (como password para crear/actualizar, pero sin incluirlo en consultas de UserInstance).
interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}interface InstanceMixin { id: number; createdAt: Date; updatedAt: Date;}/** Payload de User para registro, incluye el campo `password` */export type UserPayload = UserBase & { password: string };/** Representa el tipo User devuelto por el servidor. */export type UserInstance = UserBase & InstanceMixin;- “¿Es una buena práctica?”
- “¿Debería probarlo?”
Sin idea. ¡Sigamos!
Técnica #3: Organización con Namespaces
Aquí declaramos un espacio de nombres ModelMixins. Esto aporta algo de organización y un patrón de reutilización más claro.
Formas estandarizadas
createdAtyupdatedAtsiempre aparecen juntos.id, noIDni_id.
// `../src/types/mixins.d.ts`namespace ModelMixins { interface Identity { id: number; } interface Timestamp { createdAt: Date; updatedAt: Date; } type Instance = ModelMixins.Identity & ModelMixins.Timestamp; interface HashedPassword { passwordHash: string; } interface InputPassword { password: string; }}Uso de uniones de tipos
// `../src/types/user.d.ts`export interface UserBase { name: string; bio: string; social: Record<"facebook" | "instagram" | "github", URL>;}// Tipo único `User`, usando unión de tipos para representar// dinámicamente los estados antes y después de la creación.export type User = | (UserBase & ModelMixins.Instance & ModelMixins.HashedPassword) | (UserBase & ModelMixins.InputPassword);Si lo prefieres, también puedes exportar tipos nombrados individuales:
/** Payload de usuario para registro, incluye el campo `password` */export type UserPayload = UserBase & ModelMixins.Instance & ModelMixins.HashedPassword;/** Representa el tipo User devuelto por el servidor. */export type UserInstance = UserBase & ModelMixins.InputPassword;Uso en el mundo real
Aquí tienes una función upsert() que usa el operador in para distinguir entre los tipos UserInstance y UserPayload.
function upsert(user: User) { if ("id" in user) { // TypeScript sabe que `user` aquí tiene los campos de Instance (id, createdAt, etc.) return updateUser(user.id, user); } else { // TypeScript sabe que esto debe ser la versión `UserBase & ModelMixins.InputPassword` del usuario. return createUser(user); }}Resumen
Cubimos tres técnicas y algunas ideas de apoyo relacionadas.
Quizás te estés preguntando, ¿son buenos estos patrones? ¿Debería adoptar alguna de estas ideas?
Recursos
- TypeScript tips for legacy projects: Type only you need
- Matt Pocock’s Excellent new book
- Total TypeScript Tips