Tipado de Datos Immutables con Immer y TypeScript: Construyendo Aplicaciones Robustas
Este tutorial explora cómo trabajar con datos inmutables en TypeScript utilizando la librería Immer. Descubre cómo simplificar la gestión de estados complejos y mejorar la seguridad de tipo, facilitando el desarrollo de aplicaciones más robustas y fáciles de mantener.
🚀 Introducción a la Inmutabilidad y TypeScript
En el desarrollo de aplicaciones modernas, especialmente en frameworks como React, Redux o Vue, la inmutabilidad es un concepto fundamental. Un dato inmutable es aquel que, una vez creado, no puede ser modificado. Cuando necesitamos 'cambiar' un dato inmutable, en realidad creamos una nueva versión de ese dato con las modificaciones deseadas, dejando el original intacto.
¿Por qué la Inmutabilidad? 🤔
La inmutabilidad ofrece varios beneficios clave:
- Predecibilidad: Facilita el razonamiento sobre el estado de la aplicación, ya que los datos no cambian inesperadamente.
- Detección de Cambios: Es sencillo detectar si un objeto ha cambiado comparando sus referencias. Si la referencia es diferente, el objeto ha cambiado. Esto es crucial para optimizaciones de rendimiento en React (memoización,
shouldComponentUpdate). - Historial de Estado: Permite implementar funciones de "deshacer/rehacer" de forma natural, ya que cada "cambio" genera una nueva instantánea del estado.
- Programación Concurrente: Reduce los problemas de concurrencia y los race conditions al evitar modificaciones compartidas.
Sin embargo, trabajar directamente con la inmutabilidad en JavaScript y TypeScript puede ser tedioso. Crear copias profundas (deep copies) de objetos anidados puede llevar a código verboso y propenso a errores.
Aquí es donde entra Immer.
🎯 ¿Qué es Immer y Cómo Resuelve el Problema? ✨
Immer es una pequeña librería que te permite trabajar con estados inmutables de una manera mucho más sencilla y mutante. Es decir, escribes código como si estuvieras mutando directamente el estado, e Immer se encarga de aplicar la lógica inmutable por ti, produciendo un nuevo estado inmutable.
💡 Consejo: Immer utiliza el concepto de "proxies" de JavaScript internamente para "observar" tus mutaciones y generar el nuevo estado. Esto lo hace muy eficiente para objetos grandes y complejos.
El Problema de la Mutación Directa y TypeScript ⚠️
Considera el siguiente estado de usuario:
interface UserProfile {
id: string;
name: string;
email: string;
address: {
street: string;
city: string;
zip: string;
};
tags: string[];
}
const initialState: UserProfile = {
id: 'user-123',
name: 'Alice Smith',
email: 'alice@example.com',
address: {
street: '123 Main St',
city: 'Anytown',
zip: '12345',
},
tags: ['developer', 'typescript'],
};
// Intento de actualización 'mutable'
const updateUserAddress = (profile: UserProfile, newStreet: string): UserProfile => {
profile.address.street = newStreet; // ¡Esto muta el objeto original!
return profile;
};
const updatedProfile = updateUserAddress(initialState, '456 Oak Ave');
console.log(initialState === updatedProfile); // true, ¡mismo objeto!
console.log(initialState.address === updatedProfile.address); // true, ¡misma dirección!
Aunque TypeScript nos permite definir tipos para UserProfile, no nos impide mutar sus propiedades si el tipo no está marcado como readonly. Si bien podríamos usar Readonly<UserProfile>, esto nos obligaría a hacer copias profundas manuales en cada actualización, lo cual es tedioso.
Immer al Rescate 🛡️
Con Immer, el mismo ejemplo se vería así:
import produce from 'immer';
interface UserProfile {
id: string;
name: string;
email: string;
address: {
street: string;
city: string;
zip: string;
};
tags: string[];
}
const initialState: UserProfile = {
id: 'user-123',
name: 'Alice Smith',
email: 'alice@example.com',
address: {
street: '123 Main St',
city: 'Anytown',
zip: '12345',
},
tags: ['developer', 'typescript'],
};
const updatedProfileImmer = produce(initialState, (draft) => {
draft.address.street = '456 Oak Ave';
draft.tags.push('frontend'); // También se pueden "mutar" arrays
});
console.log(initialState === updatedProfileImmer); // false, ¡nuevo objeto!
console.log(initialState.address === updatedProfileImmer.address); // false, ¡nueva dirección!
console.log(initialState.tags === updatedProfileImmer.tags); // false, ¡nuevo array de tags!
console.log(initialState.name === updatedProfileImmer.name); // true, ¡propiedad no modificada, referencia mantenida!
Como puedes ver, escribimos el código de actualización como si fuera mutable dentro de la función produce, pero Immer se encarga de devolver un nuevo estado inmutable. ¡Y lo mejor es que TypeScript entiende esto!
🛠️ Configuración e Instalación ⚙️
Antes de sumergirnos en ejemplos más avanzados, asegúrate de tener Immer instalado en tu proyecto TypeScript.
Instalación de Immer
npm install immer
# o
yarn add immer
Immer viene con sus propios tipos de TypeScript, por lo que no necesitas instalar @types/immer por separado. Esto significa que está listo para usar de inmediato con un excelente soporte de tipado.
📖 Tipado Básico con produce en TypeScript
La función produce de Immer es el corazón de la librería. Acepta dos argumentos principales:
- El estado original (o
baseState) que queremos actualizar. - Una función
recipe(receta) que recibe undraft(borrador) del estado original. Dentro de esta función, puedes "mutar" eldraftcomo si fuera el estado real.
import produce from 'immer';
interface Product {
id: string;
name: string;
price: number;
stock: number;
details?: {
weight: number;
dimensions: string;
};
}
const initialProduct: Product = {
id: 'prod-001',
name: 'Laptop Pro X',
price: 1200,
stock: 50,
details: {
weight: 1.5,
dimensions: '35x24x1.5cm',
},
};
// Ejemplo 1: Actualizar una propiedad simple
const updatedProductName = produce(initialProduct, (draft) => {
draft.name = 'Laptop Pro X (2023 Model)';
});
// El tipo de updatedProductName es Product
console.log(updatedProductName.name); // 'Laptop Pro X (2023 Model)'
console.log(initialProduct.name); // 'Laptop Pro X'
// Ejemplo 2: Actualizar una propiedad anidada
const updatedProductStockAndWeight = produce(initialProduct, (draft) => {
draft.stock -= 5;
if (draft.details) {
draft.details.weight = 1.4; // TypeScript entiende que draft.details es Product['details'] | undefined
}
});
// El tipo es Product
console.log(updatedProductStockAndWeight.stock); // 45
console.log(updatedProductStockAndWeight.details?.weight); // 1.4
Immer infiere automáticamente los tipos correctamente. La función produce devuelve el mismo tipo que el baseState original.
Manejo de undefined y Propiedades Opcionales ❓
Cuando trabajas con propiedades opcionales o anidadas que pueden ser undefined, Immer y TypeScript colaboran de forma excelente. Debes asegurarte de que accedes a las propiedades anidadas de forma segura, tal como lo harías con cualquier objeto potencialmente undefined.
import produce from 'immer';
interface Order {
id: string;
items: { productId: string; quantity: number }[];
shippingAddress?: {
street: string;
city: string;
};
}
const order1: Order = {
id: 'ord-001',
items: [{ productId: 'A', quantity: 2 }],
};
const order2: Order = {
id: 'ord-002',
items: [{ productId: 'B', quantity: 1 }],
shippingAddress: {
street: 'Main St',
city: 'Metropolis',
},
};
// Actualizar dirección en un pedido que ya la tiene
const updatedOrder2 = produce(order2, (draft) => {
if (draft.shippingAddress) { // Es importante la comprobación para TypeScript
draft.shippingAddress.city = 'Gotham City';
}
});
// Añadir dirección a un pedido que no la tiene
const updatedOrder1 = produce(order1, (draft) => {
if (!draft.shippingAddress) { // Comprobación para evitar errores y añadirla
draft.shippingAddress = {
street: 'New St',
city: 'Smallville',
};
}
});
console.log(updatedOrder2.shippingAddress?.city); // 'Gotham City'
console.log(updatedOrder1.shippingAddress?.city); // 'Smallville'
🔄 Trabajar con Arrays y Mapas Inmutables
Immer también simplifica las operaciones con arrays y mapas, que a menudo son fuentes de errores de mutación sutiles.
Operaciones con Arrays
import produce from 'immer';
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface AppState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
}
const initialAppState: AppState = {
todos: [
{ id: 1, text: 'Learn Immer', completed: false },
{ id: 2, text: 'Build an app', completed: false },
],
filter: 'all',
};
// Añadir un nuevo todo
const stateWithNewTodo = produce(initialAppState, (draft) => {
draft.todos.push({ id: 3, text: 'Deploy to production', completed: false });
});
// Marcar un todo como completado
const stateWithCompletedTodo = produce(initialAppState, (draft) => {
const todo = draft.todos.find((t) => t.id === 1);
if (todo) {
todo.completed = true;
}
});
// Eliminar un todo
const stateWithoutTodo2 = produce(initialAppState, (draft) => {
draft.todos = draft.todos.filter((t) => t.id !== 2);
});
console.log(stateWithNewTodo.todos.length); // 3
console.log(stateWithCompletedTodo.todos[0].completed); // true
console.log(stateWithoutTodo2.todos.some((t) => t.id === 2)); // false
Fíjate cómo podemos usar push, filter, y acceder a elementos del array para mutarlos directamente. Immer se encargará de crear nuevas copias del array y de los objetos afectados.
Operaciones con Mapas (Map y Set)
Immer soporta Map y Set de JavaScript de forma nativa. Puedes mutarlos directamente dentro de la función recipe.
import produce from 'immer';
interface UserSettings {
theme: 'dark' | 'light';
notifications: Map<string, boolean>; // Map<feature, enabled>
}
const initialSettings: UserSettings = {
theme: 'dark',
notifications: new Map([
['email', true],
['sms', false],
]),
};
const updatedSettings = produce(initialSettings, (draft) => {
draft.theme = 'light';
draft.notifications.set('push', true); // Añadir nuevo elemento
draft.notifications.set('email', false); // Modificar existente
draft.notifications.delete('sms'); // Eliminar elemento
});
console.log(updatedSettings.theme); // 'light'
console.log(updatedSettings.notifications.has('push')); // true
console.log(updatedSettings.notifications.get('email')); // false
console.log(updatedSettings.notifications.has('sms')); // false
🧪 Integración Avanzada con React Reducers y Redux Toolkit
Immer brilla especialmente en la gestión de estados complejos, como los que se encuentran en React con useReducer o en aplicaciones Redux. Redux Toolkit, de hecho, usa Immer internamente.
Ejemplo con useReducer en React
Aquí tienes cómo se vería un reducer de React tipado con Immer:
import React, { useReducer, useCallback } from 'react';
import produce from 'immer';
// Definición de tipos para el estado
interface Task {
id: string;
text: string;
completed: boolean;
}
interface AppState {
tasks: Task[];
nextId: number;
}
// Definición de tipos para las acciones
type AppAction =
| { type: 'ADD_TASK'; text: string }
| { type: 'TOGGLE_TASK'; id: string }
| { type: 'DELETE_TASK'; id: string };
// Estado inicial
const initialState: AppState = {
tasks: [],
nextId: 0,
};
// Reducer utilizando Immer's produce
const appReducer = produce((draft: AppState, action: AppAction) => {
switch (action.type) {
case 'ADD_TASK':
draft.tasks.push({
id: `task-${draft.nextId++}`,
text: action.text,
completed: false,
});
break;
case 'TOGGLE_TASK':
const taskToToggle = draft.tasks.find((task) => task.id === action.id);
if (taskToToggle) {
taskToToggle.completed = !taskToToggle.completed;
}
break;
case 'DELETE_TASK':
draft.tasks = draft.tasks.filter((task) => task.id !== action.id);
break;
default:
// No es necesario lanzar un error si no se encuentra un tipo de acción
// En Immer, si no se modifica el draft, produce devuelve el estado original
break;
}
});
const TaskApp: React.FC = () => {
const [state, dispatch] = useReducer(appReducer, initialState);
const [taskText, setTaskText] = React.useState('');
const handleAddTask = useCallback(() => {
if (taskText.trim()) {
dispatch({ type: 'ADD_TASK', text: taskText });
setTaskText('');
}
}, [dispatch, taskText]);
return (
<div>
<h1>Task List</h1>
<div>
<input
type="text"
value={taskText}
onChange={(e) => setTaskText(e.target.value)}
placeholder="Add a new task"
/>
<button onClick={handleAddTask}>Add Task</button>
</div>
<ul>
{state.tasks.map((task) => (
<li key={task.id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => dispatch({ type: 'TOGGLE_TASK', id: task.id })}
/>
<span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.text}
</span>
<button onClick={() => dispatch({ type: 'DELETE_TASK', id: task.id })}>
Delete
</button>
</li>
))}
</ul>
</div>
);
};
export default TaskApp;
Observa cómo el appReducer está envuelto en produce. Dentro de él, podemos mutar draft.tasks y draft.nextId directamente, y Immer se encarga de la inmutabilidad. TypeScript inferirá los tipos correctamente para state y action dentro del reducer.
Diagrama de Flujo: Immer en un Reducer
Tipado de Reducers con Redux Toolkit (que usa Immer)
Redux Toolkit ya utiliza Immer bajo el capó en sus createSlice y createReducer. Esto significa que puedes escribir lógica de mutación directamente en tus reducers sin preocuparte por la inmutabilidad manual, y TypeScript lo gestiona perfectamente.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface AuthState {
user: { id: string; name: string; email: string } | null;
isAuthenticated: boolean;
loading: boolean;
}
const initialState: AuthState = {
user: null,
isAuthenticated: false,
loading: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginPending: (state) => {
state.loading = true;
},
loginSuccess: (state, action: PayloadAction<{ id: string; name: string; email: string }>) => {
state.user = action.payload;
state.isAuthenticated = true;
state.loading = false;
},
loginFailure: (state) => {
state.user = null;
state.isAuthenticated = false;
state.loading = false;
},
logout: (state) => {
state.user = null;
state.isAuthenticated = false;
},
// Ejemplo de actualización anidada
updateUserName: (state, action: PayloadAction<string>) => {
if (state.user) {
state.user.name = action.payload;
}
},
},
});
export const { loginPending, loginSuccess, loginFailure, logout, updateUserName } = authSlice.actions;
export default authSlice.reducer;
Aquí, state dentro de cada reducer es un Draft<AuthState> (un tipo interno de Immer que hace que state sea mutable para la receta) y TypeScript lo infiere correctamente. Puedes mutarlo directamente, y Redux Toolkit, gracias a Immer, producirá un nuevo estado inmutable.
💡 Patrones Avanzados y Consejos de Tipado
Usando produce con un solo argumento (currying) 🍛
produce puede usarse con currying, lo que es útil para crear funciones de actualización reutilizables.
import produce from 'immer';
interface Settings {
darkMode: boolean;
fontSize: number;
notifications: boolean;
}
const initialSettings: Settings = {
darkMode: false,
fontSize: 16,
notifications: true,
};
// Función para alternar el modo oscuro
const toggleDarkMode = produce((draft: Settings) => {
draft.darkMode = !draft.darkMode;
});
// Función para ajustar el tamaño de fuente
const setFontSize = produce((draft: Settings, newSize: number) => {
draft.fontSize = newSize;
});
let currentSettings = initialSettings;
currentSettings = toggleDarkMode(currentSettings);
console.log(currentSettings.darkMode); // true
currentSettings = setFontSize(currentSettings, 18);
console.log(currentSettings.fontSize); // 18
// El tipo de toggleDarkMode es (base: Settings) => Settings
// El tipo de setFontSize es (base: Settings, newSize: number) => Settings
TypeScript entiende perfectamente los tipos de estas funciones currificadas, haciendo que el código sea más robusto y reutilizable.
El operador current de Immer 📖
Dentro de una función recipe, a veces necesitas acceder al valor original del estado sin las mutaciones que estás haciendo en el draft. Para esto, Immer proporciona la función current.
import produce, { current } from 'immer';
interface Item {
id: number;
name: string;
price: number;
}
interface CartState {
items: Item[];
totalPrice: number;
}
const initialCart: CartState = {
items: [
{ id: 1, name: 'Apple', price: 1.0 },
{ id: 2, name: 'Banana', price: 0.5 },
],
totalPrice: 1.5,
};
const updateCart = produce((draft: CartState, itemIdToRemove: number) => {
const itemIndex = draft.items.findIndex((item) => item.id === itemIdToRemove);
if (itemIndex !== -1) {
// Acceder al precio original del item antes de eliminarlo
// 'current' nos da el objeto sin mutaciones que estamos a punto de hacer
const removedItemPrice = current(draft.items[itemIndex]).price;
draft.items.splice(itemIndex, 1);
draft.totalPrice -= removedItemPrice;
}
});
const updatedCart = updateCart(initialCart, 1);
console.log(updatedCart.items.length); // 1
console.log(updatedCart.totalPrice); // 0.5 (solo la banana)
Pro Immer.setAutoFreeze(false) y Rendimiento
Por defecto, Immer "congela" (freezes) el nuevo estado producido. Esto significa que el nuevo estado es profundamente inmutable (incluso si intentas mutarlo fuera de Immer, JavaScript lanzará un error en modo estricto). Esto es genial para la depuración y para asegurar la inmutabilidad.
Sin embargo, el proceso de congelación puede tener una pequeña penalización de rendimiento para estados extremadamente grandes. Si el rendimiento es crítico y estás seguro de que no mutarás el estado resultante, puedes desactivar el congelamiento automático.
import produce, { setAutoFreeze } from 'immer';
// Desactivar el congelamiento automático (generalmente al inicio de tu app)
setAutoFreeze(false);
interface BigState {
data: number[];
config: {
isEnabled: boolean;
settings: Record<string, any>;
};
}
const initialBigState: BigState = {
data: Array.from({ length: 100000 }, (_, i) => i),
config: { isEnabled: true, settings: {} },
};
const updatedBigState = produce(initialBigState, (draft) => {
draft.data.push(100001);
draft.config.isEnabled = false;
});
// updatedBigState ahora no está congelado, por lo que podrías mutarlo (aunque no deberías)
// Object.isFrozen(updatedBigState) // false
La mayoría de las aplicaciones no necesitarán desactivar setAutoFreeze. Es una optimización para casos muy específicos.
✅ Conclusión y Mejores Prácticas
Immer, combinado con TypeScript, es una herramienta extremadamente poderosa para manejar la inmutabilidad en tus aplicaciones. Simplifica drásticamente el código de actualización de estado, lo hace más legible y, crucialmente, mejora la seguridad de tipo.
Resumen de Beneficios:
- Código Conciso: Escribe lógica de mutación simple, deja que Immer se ocupe de la inmutabilidad.
- Seguridad de Tipo: TypeScript infiere correctamente los tipos, incluso con el
draftmutable. - Rendimiento: Solo copia las partes del estado que han cambiado.
- Mantenibilidad: Reduce errores relacionados con mutaciones accidentales.
Mejores Prácticas con Immer y TypeScript:
- Define Tipos Claros: Siempre comienza definiendo interfaces y tipos claros para tu estado. Esto es la base para que Immer y TypeScript funcionen bien.
- Usa
produceConsistentemente: Envuelve todas tus funciones de actualización de estado enproducepara asegurar la inmutabilidad. - No Mutes Fuera de
produce: Una vez que obtienes el estado de retorno deproduce, trátalo como inmutable y no lo modifiques directamente. - Haz Comprobaciones de
undefined: Aunque Immer facilita la mutación, las comprobaciones para propiedades opcionales (if (draft.property)) siguen siendo necesarias para la seguridad de tipo y para evitar errores en tiempo de ejecución. - Considera el Currying: Utiliza la forma currificada de
producepara crear funciones de actualización reutilizables que acepten argumentos adicionales.
Al adoptar Immer y sus patrones con TypeScript, estás un paso más cerca de construir aplicaciones escalables, robustas y fáciles de depurar. ¡Feliz codificación inmutable!
Tutoriales relacionados
- Tipado de Eventos en el DOM con TypeScript: Guía Completa para Interfaces y Manejadoresintermediate10 min
- Tipado de Configuración y Variables de Entorno en TypeScript: Robustez en tu Aplicaciónintermediate15 min
- Tipado de Genéricos en Funciones y Clases con TypeScript: Flexibilidad y Seguridadintermediate15 min
- Simplificando la Configuración con Módulos Declarativos de Entorno en TypeScriptintermediate15 min
- Desentrañando los Módulos de Declaración en TypeScript: Globales vs. de Módulointermediate18 min
Comentarios (0)
Aún no hay comentarios. ¡Sé el primero!