DanLevy.net

Tipos guerrilla en TypeScript

Diseño tipográfico rebelde

Hero image for Tipos guerrilla en TypeScript

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

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.

⚠️ Desde (aproximadamente) TypeScript v3, el Language Server trunca los tooltips, omitiendo propiedades anidadas.
💡 Hay trucos para mejorar un poco las cosas. Mantén presionado Cmd o Ctrl y 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?

✅ ¡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];
  1. Crear tipos estructurados “Primarios” grandes.
  2. 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 datos
interface InstanceMixin {
id: number;
createdAt: Date;
updatedAt: Date;
}
// Una **instancia** de User - con todos los campos
type 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;
  1. “¿Es una buena práctica?”
  2. “¿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

// `../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