Tipos avanzados de TypeScript: Condicionales, Mapped Types y la magia de Template Literals
Una inmersión profunda en las características más poderosas del sistema de tipos de TypeScript: tipos condicionales, mapped types, la palabra clave infer y template literal types. Patrones reales que vas a usar de verdad.
La mayoría de los desarrolladores de TypeScript se estancan en el mismo punto. Conocen interfaces, genéricos, union types. Saben tipar un componente de React. Pueden hacer que las líneas rojas desaparezcan. Pero tratan el sistema de tipos como un mal necesario — algo que existe para prevenir bugs, no algo que los ayuda activamente a diseñar mejor software.
Yo me quedé estancado en ese punto durante unos dos años. Lo que me sacó de ahí fue darme cuenta de que el sistema de tipos de TypeScript es un lenguaje de programación en sí mismo. Tiene condicionales, bucles, pattern matching, recursión y manipulación de strings. Una vez que internalizas eso, todo cambia. Dejas de pelear contra el compilador y empiezas a colaborar con él.
Este post cubre las funcionalidades a nivel de tipos que uso constantemente en código de producción. No son ejercicios académicos — son patrones reales que me han salvado de bugs reales.
La mentalidad de programación a nivel de tipos#
Antes de meternos en la sintaxis, quiero replantear cómo piensas sobre los tipos.
En programación a nivel de valores, escribes funciones que transforman datos en tiempo de ejecución:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}En programación a nivel de tipos, escribes tipos que transforman otros tipos en tiempo de compilación:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];El modelo mental es el mismo — entrada, transformación, salida. La diferencia es que el código a nivel de tipos se ejecuta durante la compilación, no en runtime. No produce nada de JavaScript. Su único propósito es hacer que los estados imposibles sean irrepresentables.
Si alguna vez escribiste una función donde tres de los cuatro parámetros solo tienen sentido en ciertas combinaciones, o donde el tipo de retorno depende de lo que le pases, ya necesitabas programación a nivel de tipos. Solo que quizás no sabías que TypeScript podía expresarlo.
Tipos condicionales#
Los tipos condicionales son el if/else del sistema de tipos. La sintaxis se parece a un operador ternario:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueLa palabra clave extends aquí no significa herencia. Significa "es asignable a". Piénsalo como "¿encaja T en la forma de string?"
Construyendo tus propios tipos utilitarios#
Reconstruyamos algunos de los tipos utilitarios integrados de TypeScript para entender cómo funcionan.
NonNullable — remueve null y undefined de un union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringEl tipo never es el "conjunto vacío" — remueve algo de un union. Cuando una rama de un tipo condicional resuelve a never, ese miembro del union simplemente desaparece.
Extract — mantiene solo los miembros del union que coinciden con una restricción:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — lo opuesto, remueve los miembros que coinciden:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanTipos condicionales distributivos#
Aquí es donde las cosas se ponen interesantes, y donde la mayoría de la gente se confunde. Cuando pasas un tipo union a un tipo condicional, TypeScript distribuye la condición sobre cada miembro del union individualmente.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// You might expect: (string | number)[]
// Actual result: string[] | number[]TypeScript no evalúa (string | number) extends unknown. En cambio, evalúa string extends unknown y number extends unknown por separado, y luego une los resultados.
Es por eso que Extract y Exclude funcionan como lo hacen. La distribución sucede automáticamente cuando T es un parámetro de tipo "desnudo" (no envuelto en nada).
Si quieres prevenir la distribución, envuelve ambos lados en una tupla:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]Me ha mordido esto más veces de las que me gustaría admitir. Si tu tipo condicional está dando resultados inesperados con inputs de tipo union, la distribución es casi siempre la razón.
Ejemplo práctico: manejador de respuestas de API#
Aquí hay un patrón que uso cuando manejo respuestas de API que pueden tener éxito o fallar:
type ApiResponse<T> =
| { status: "success"; data: T; error: never }
| { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Result: stringEsto es más limpio que escribir type guards manuales para cada forma de respuesta.
La palabra clave infer#
infer es pattern matching para tipos. Te permite declarar una variable de tipo dentro de un tipo condicional que TypeScript resolverá por ti.
Piénsalo como: "Sé que este tipo tiene cierta forma. Extráeme la parte que me interesa."
Extrayendo tipos de retorno#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript ve el patrón (...args) => something, hace match con tu función contra él, y asigna el tipo de retorno a R.
Extrayendo tipos de parámetros#
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]Desenvolviendo Promises#
Este lo uso todo el tiempo cuando trabajo con funciones asíncronas:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (passthrough)Para promises profundamente anidadas:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numberExtrayendo tipos de elementos de arrays#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberMúltiples infer en un solo tipo#
Puedes usar infer múltiples veces para extraer diferentes partes:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }Caso real: extracción de eventos con tipado seguro#
Aquí hay un patrón que uso en sistemas orientados a eventos:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// Extract all user-related events
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
// Get the payload for user events only
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }Mapped Types#
Los mapped types te permiten crear nuevos tipos de objeto transformando cada propiedad de un tipo existente. Son el map() del sistema de tipos.
Lo básico#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T te da un union de todas las claves de propiedades. [K in ...] itera sobre cada una. T[K] es el tipo de esa propiedad.
Construyendo tipos utilitarios desde cero#
Required — hace todas las propiedades obligatorias:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};La sintaxis -? remueve el modificador opcional. De manera similar, -readonly remueve readonly.
Pick — selecciona propiedades específicas:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit — remueve propiedades específicas:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record — crea un tipo de objeto con claves y tipos de valor específicos:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }Remapeo de claves con as#
TypeScript 4.1 introdujo el remapeo de claves, que te permite transformar nombres de propiedades:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }También puedes filtrar propiedades remapeando a never:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }Esto es increíblemente poderoso. Estás simultáneamente iterando sobre propiedades, filtrándolas y transformando sus nombres — todo a nivel de tipos.
Ejemplo práctico: tipos para validación de formularios#
He usado este patrón en varias librerías de formularios:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// Now your form state is fully typed based on the fields
type MyFormState = FormState<FormFields>;Agregas un campo a FormFields, y cada tipo relacionado se actualiza automáticamente. Remueves un campo, y el compilador detecta cada referencia. Este es el tipo de cosas que previene categorías enteras de bugs.
Template Literal Types#
Los template literal types te permiten manipular strings a nivel de tipos. Usan la misma sintaxis de backticks que los template literals de JavaScript, pero para tipos.
Manipulación básica de strings#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript también proporciona tipos integrados de manipulación de strings:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Patrones de nombres de eventos#
Aquí es donde los template literal types realmente brillan. Este es un patrón que uso para sistemas de eventos con tipado seguro:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"Nueve eventos a partir de dos parámetros de tipo. Y todos son type-safe.
Patrones CSS-in-TypeScript#
Los template literal types pueden forzar patrones CSS a nivel de tipos:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// implementation
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Error: not a valid CSS value
// setWidth("big"); // ErrorParseando strings con template literals#
Puedes usar template literal types con infer para parsear strings:
type ParseRoute<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"Esta es la base de cómo funcionan las librerías de routing con tipado seguro como tRPC. El string /users/:userId/posts/:postId se parsea a nivel de tipos para extraer los nombres de los parámetros.
Combinando template literals con mapped types#
Aquí es donde las cosas se vuelven realmente poderosas:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }A partir de una simple interfaz de configuración, obtienes event handlers completamente tipados con los tipos de parámetros correctos. Este es el tipo de código a nivel de tipos que realmente ahorra tiempo de desarrollo.
Avanzado: tipos de rutas con notación de punto#
Aquí hay un patrón más avanzado — generar todas las posibles rutas con notación de punto a través de un objeto anidado:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"Ahora puedes escribir una función getConfig que autocomplete cada ruta posible y retorne el tipo correcto para cada una:
type GetValueByPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// implementation
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // type: string
const port = getConfig("database.port"); // type: number
const username = getConfig("database.credentials.username"); // type: stringAutocompletado completo, seguridad de tipos total, cero overhead en runtime por los tipos.
Tipos recursivos#
TypeScript soporta definiciones de tipos recursivas, lo que te permite manejar estructuras anidadas de profundidad arbitraria.
Deep Partial#
El Partial<T> integrado solo hace opcionales las propiedades del primer nivel. Para objetos anidados, necesitas recursión:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// Every nested property is now optionalObserva la verificación de arrays: sin ella, los arrays serían tratados como objetos y sus índices numéricos se volverían opcionales, que no es lo que quieres.
Deep Readonly#
Mismo patrón, diferente modificador:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};La verificación de Function es importante — sin ella, las propiedades de tipo función tendrían readonly aplicado a su estructura interna, lo cual no tiene sentido.
Tipo JSON#
Aquí hay un tipo recursivo clásico — representando cualquier valor JSON válido:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}Esto es mucho mejor que usar any para el parseo de JSON. Le dice al consumidor "obtendrás algo de vuelta, pero necesitas hacer narrowing antes de usarlo". Lo cual es exactamente la verdad.
Tipo recursivo para menús anidados#
Un ejemplo práctico — tipando una estructura de menú de navegación:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];La propiedad recursiva children te da anidamiento infinito con seguridad de tipos completa.
Patrones del mundo real#
Déjame compartir algunos patrones que realmente he usado en sistemas de producción.
Patrón Builder con tipos#
El patrón Builder se vuelve significativamente más útil cuando el sistema de tipos rastrea lo que se ha configurado:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// This compiles:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// This fails at compile time — missing .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Error: 'database' is missingEl método build() solo se puede llamar cuando todos los campos requeridos han sido configurados. Esto se detecta en tiempo de compilación, no en runtime.
Event Emitter con tipado seguro#
Aquí hay un event emitter donde los tipos del payload son forzados:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// Usage
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Fully typed — IDE autocompletes event names and payload shapes
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Type error: payload doesn't match
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
// Type error: event doesn't exist
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEventsHe usado este patrón en tres proyectos de producción diferentes. Detecta categorías enteras de bugs relacionados con eventos en tiempo de compilación.
Uniones discriminadas con verificación exhaustiva#
Las uniones discriminadas son probablemente el patrón individual más útil en TypeScript. Combinadas con verificación exhaustiva, garantizan que manejes cada caso:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// This line ensures exhaustive checking
const _exhaustive: never = shape;
return _exhaustive;
}
}Si alguien agrega una nueva variante de forma (digamos "pentagon"), esta función fallará al compilar porque never no puede recibir un valor. El compilador te obliga a manejar cada caso.
Yo llevo esto más allá con una función auxiliar:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}Máquinas de estado con tipado seguro#
Las uniones discriminadas también modelan máquinas de estado de manera hermosa:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(
state: ConnectionState,
action: ConnectionAction
): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}Cada estado solo tiene las propiedades que tienen sentido para ese estado. No puedes acceder a socket cuando estás desconectado. No puedes acceder a error cuando estás conectado. El sistema de tipos fuerza las restricciones de la máquina de estados.
Branded Types para seguridad de dominio#
Un patrón más que encuentro esencial — usar branded types para prevenir la mezcla de valores que comparten el mismo tipo subyacente:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Error! OrderId is not assignable to UserIdTanto UserId como OrderId son strings en runtime. Pero en tiempo de compilación, son tipos distintos. Literalmente no puedes pasar un ID de orden donde se espera un ID de usuario. Esto ha detectado bugs reales en cada proyecto donde lo he usado.
Errores comunes#
Los tipos avanzados son poderosos, pero vienen con trampas. Esto es lo que he aprendido por las malas.
Límites de referencias circulares#
TypeScript tiene un límite de profundidad de recursión para tipos (actualmente alrededor de 50 niveles, aunque varía). Si te pasas, obtendrás el temido error "Type instantiation is excessively deep and possibly infinite".
// This will hit the recursion limit for deeply nested objects
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Fix: Add a depth counter
type DeepPartialSafe<T, Depth extends number[] = []> =
Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartialSafe<T[K], [...Depth, 0]>
: T[K];
};El truco del contador de profundidad usa una tupla que crece con cada paso recursivo. Cuando llega a tu límite, la recursión se detiene.
Implicaciones de rendimiento#
Los tipos complejos pueden ralentizar significativamente tu editor y los tiempos de compilación. He visto proyectos donde un solo tipo excesivamente ingenioso agregó 3 segundos al ciclo de retroalimentación de cada pulsación de tecla.
Señales de advertencia:
- Tu IDE tarda más de 2 segundos en mostrar el autocompletado
tsc --noEmittarda notablemente más después de agregar un tipo- Ves errores de "Type instantiation is excessively deep"
// This is too clever — it generates a combinatorial explosion
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Don't do this with more than 5-6 elementsCuándo NO usar tipos avanzados#
Esta podría ser la sección más importante. Los tipos avanzados deben usarse cuando previenen bugs o mejoran la experiencia del desarrollador. No deben usarse para lucirte.
No los uses cuando:
- Un simple
Record<string, unknown>sería suficiente - El tipo solo se usa en un lugar y un tipo concreto sería más claro
- Pasas más tiempo depurando el tipo de lo que ahorraría
- Tu equipo no puede leerlo ni mantenerlo
- Una verificación en runtime sería más apropiada
Sí, úsalos cuando:
- Tienes un patrón que se repite en muchos tipos (mapped types)
- El tipo de retorno de una función depende de su entrada (tipos condicionales)
- Estás construyendo una API de librería que necesita ser autodocumentada
- Quieres hacer que los estados ilegales sean irrepresentables
- Estás cansado de que la misma categoría de bug aparezca en code reviews
// Over-engineered — don't do this for a simple config
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Just do this
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}Mi regla general: si explicar el tipo toma más tiempo que explicar el bug que previene, simplifícalo.
Depurando tipos complejos#
Cuando un tipo complejo no funciona, uso este helper para "ver" lo que TypeScript ha resuelto:
// Expands a type for inspection in IDE hover tooltips
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Use it to debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Now hover over Debug in your IDE to see the resolved typeEl truco de & {} fuerza a TypeScript a evaluar el tipo ansiosamente en lugar de mostrarte el alias del tipo. Me ha ahorrado horas de confusión.
Otra técnica — aislar y probar incrementalmente:
// Instead of debugging this all at once:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Break it apart:
type AfterStep1 = StepOne<Input>; // hover to check
type AfterStep2 = StepTwo<AfterStep1>; // hover to check
type AfterStep3 = StepThree<AfterStep2>; // hover to checkResumen#
- Los tipos condicionales (
T extends U ? X : Y) son if/else para tipos. Cuidado con el comportamiento distributivo con unions. inferes pattern matching — úsalo para extraer tipos de estructuras como firmas de funciones, promises y arrays.- Los mapped types (
{ [K in keyof T]: ... }) iteran sobre propiedades. Combínalos conaspara remapeo y filtrado de claves. - Los template literal types manipulan strings a nivel de tipos. Combinados con mapped types, son increíblemente poderosos para el diseño de APIs.
- Los tipos recursivos manejan estructuras anidadas pero necesitan límites de profundidad para evitar explosiones del compilador.
- Las uniones discriminadas con verificación exhaustiva son el patrón individual de mayor valor en TypeScript. Úsalas en todas partes.
- Los branded types previenen la mezcla de valores que comparten el mismo tipo subyacente. Simples de implementar, alto impacto.
- No sobreingenierices los tipos. Si el tipo es más difícil de entender que el bug que previene, simplifícalo. El objetivo es hacer tu codebase más seguro, no ganar una competencia de type golf.
El sistema de tipos de TypeScript es Turing completo, lo que significa que puedes hacer casi cualquier cosa con él. El arte está en saber cuándo deberías.