tutoriales.com

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.

Intermedio15 min de lectura6 views
Reportar error

🚀 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.

⚠️ Advertencia: Mutar el estado directamente puede llevar a errores difíciles de depurar, especialmente en aplicaciones grandes con componentes que dependen del estado inmutable para sus optimizaciones de renderizado.

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:

  1. El estado original (o baseState) que queremos actualizar.
  2. Una función recipe (receta) que recibe un draft (borrador) del estado original. Dentro de esta función, puedes "mutar" el draft como 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'
📌 Nota: Dentro de la función `recipe`, el `draft` se comporta como si fuera completamente mutable. TypeScript lo entiende y permite asignaciones directas a propiedades anidadas que de otra manera serían `readonly` si el estado base fuera `Readonly` fuera de Immer.

🔄 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
🔥 Importante: Immer sólo "observa" mutaciones en objetos y arrays. Si trabajas con tipos primitivos (números, cadenas, booleanos) o `null`/`undefined`, simplemente se asignan como nuevos valores. Para `Map` y `Set`, Immer gestiona sus mutaciones internas.

🧪 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

Estado Original (Immutable) Llamar a produce(Estado Original, (draft, action) => { ... }) Mutar draft No mutar draft Immer crea nuevo estado inmutable Immer devuelve el estado original Nuevo Estado (Immutable)
Paso 1: Recibir `baseState` y `action` en `produce` (reducer).
Paso 2: Immer crea un `draft` mutable (proxy) del `baseState`.
Paso 3: La función `recipe` modifica el `draft` como si fuera mutable.
Paso 4: Immer detecta las mutaciones en el `draft`.
Paso 5: Immer genera un nuevo `baseState` inmutable con los cambios.
Paso 6: Si no hubo cambios, Immer devuelve el `baseState` original por referencia.

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)
⚠️ Advertencia: Usa `current` con moderación. Si abusas de `current`, podrías estar haciendo copias manuales que Immer ya haría por ti, o complicando el razonamiento sobre el `draft`. Es para casos donde realmente necesitas el valor original no mutado durante la misma operación.

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
Impacto en Rendimiento con autoFreeze

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 draft mutable.
  • Rendimiento: Solo copia las partes del estado que han cambiado.
  • Mantenibilidad: Reduce errores relacionados con mutaciones accidentales.

Mejores Prácticas con Immer y TypeScript:

  1. Define Tipos Claros: Siempre comienza definiendo interfaces y tipos claros para tu estado. Esto es la base para que Immer y TypeScript funcionen bien.
  2. Usa produce Consistentemente: Envuelve todas tus funciones de actualización de estado en produce para asegurar la inmutabilidad.
  3. No Mutes Fuera de produce: Una vez que obtienes el estado de retorno de produce, trátalo como inmutable y no lo modifiques directamente.
  4. 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.
  5. Considera el Currying: Utiliza la forma currificada de produce para 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

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!