JavaScript
Tutorial JavaScript: Inmutabilidad y programación funcional pura
JavaScript Inmutabilidad: Explora sus principios y beneficios para un código más seguro y mantenible.
Aprende JavaScript y certifícatePrincipios de inmutabilidad: No modificación de datos después de su creación
La inmutabilidad es uno de los pilares fundamentales de la programación funcional en JavaScript. Este concepto, aparentemente simple, transforma radicalmente la forma en que diseñamos y estructuramos nuestro código. Un dato inmutable es aquel que, una vez creado, no puede ser modificado en ningún momento de su ciclo de vida.
¿Qué significa realmente la inmutabilidad?
En términos prácticos, la inmutabilidad implica que cuando necesitamos cambiar un valor, en lugar de modificar el original, creamos una nueva copia con las modificaciones deseadas. El valor original permanece intacto.
Veamos un ejemplo sencillo que ilustra la diferencia entre un enfoque mutable e inmutable:
// Enfoque mutable (modificando el original)
const addItemMutable = (cart, item) => {
cart.push(item); // Modifica el array original
return cart;
};
// Enfoque inmutable (creando una nueva copia)
const addItemImmutable = (cart, item) => {
return [...cart, item]; // Crea un nuevo array
};
// Ejemplo de uso
const shoppingCart = ['apple', 'orange'];
const mutableCart = addItemMutable(shoppingCart, 'banana');
console.log(shoppingCart); // ['apple', 'orange', 'banana'] - ¡El original cambió!
const originalCart = ['apple', 'orange'];
const newCart = addItemImmutable(originalCart, 'banana');
console.log(originalCart); // ['apple', 'orange'] - El original se mantiene intacto
console.log(newCart); // ['apple', 'orange', 'banana'] - Tenemos una nueva versión
Beneficios de la inmutabilidad
La inmutabilidad no es solo una preferencia estilística, sino que ofrece ventajas tangibles:
- Predictibilidad: El comportamiento del código es más predecible cuando los datos no cambian inesperadamente.
- Depuración simplificada: Cuando un valor nunca cambia después de su creación, es más fácil rastrear su estado a lo largo del tiempo.
- Concurrencia segura: Los datos inmutables son inherentemente seguros en entornos concurrentes, ya que no pueden ser modificados por múltiples procesos simultáneamente.
- Facilita el testing: Las pruebas son más sencillas cuando podemos confiar en que los datos de entrada no serán alterados.
- Historial de cambios: Cada modificación genera un nuevo estado, lo que facilita implementar funcionalidades como "deshacer" o mantener un historial.
Inmutabilidad con tipos primitivos y objetos
En JavaScript, los tipos primitivos (string, number, boolean, etc.) son inmutables por naturaleza:
let name = "Alice";
name.toUpperCase(); // Esto no modifica 'name', solo devuelve un nuevo valor
console.log(name); // Sigue siendo "Alice"
// Para cambiar el valor, debemos reasignar:
name = name.toUpperCase(); // Ahora name es "ALICE"
Sin embargo, los objetos y arrays son mutables por defecto, lo que significa que debemos aplicar técnicas específicas para trabajar con ellos de forma inmutable:
// Objeto mutable (forma incorrecta en programación funcional)
const user = { name: "Alice", age: 30 };
user.age = 31; // Mutación directa
// Enfoque inmutable (forma correcta)
const user = { name: "Alice", age: 30 };
const updatedUser = { ...user, age: 31 }; // Crea un nuevo objeto
Inmutabilidad en estructuras anidadas
Mantener la inmutabilidad en estructuras de datos complejas requiere especial atención:
const company = {
name: "TechCorp",
address: {
city: "San Francisco",
country: "USA"
},
employees: ["Alice", "Bob"]
};
// Actualizar una propiedad anidada de forma inmutable
const updatedCompany = {
...company,
address: {
...company.address,
city: "New York"
}
};
// Añadir un empleado de forma inmutable
const companyWithNewEmployee = {
...company,
employees: [...company.employees, "Charlie"]
};
Inmutabilidad y rendimiento
Una preocupación común sobre la inmutabilidad es su impacto en el rendimiento, ya que crear nuevas copias de datos grandes podría ser costoso. Sin embargo:
- Las optimizaciones modernas de JavaScript (como la compartición estructural) mitigan muchos de estos problemas.
- Existen bibliotecas especializadas como Immutable.js o Immer que implementan estructuras de datos inmutables eficientes.
- El beneficio en mantenibilidad y reducción de errores suele superar el costo en rendimiento.
// Usando Immer (ejemplo conceptual)
import produce from 'immer';
const nextState = produce(currentState, draft => {
draft.deeply.nested.property = newValue;
});
Aplicando inmutabilidad en el código diario
Para adoptar un estilo de programación inmutable, podemos seguir estas prácticas:
- Preferir métodos no mutadores de arrays como
map()
,filter()
yreduce()
en lugar depush()
,splice()
, etc. - Utilizar el operador spread (
...
) para crear copias de objetos y arrays. - Evitar asignaciones directas a propiedades de objetos después de su creación.
- Considerar el uso de
Object.freeze()
para prevenir modificaciones accidentales.
// Transformar un array de forma inmutable
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
// Filtrar elementos de forma inmutable
const evenNumbers = numbers.filter(n => n % 2 === 0);
// Combinar operaciones de forma inmutable
const sum = numbers.reduce((total, n) => total + n, 0);
La inmutabilidad es un cambio de paradigma que puede resultar desafiante al principio, especialmente si estamos acostumbrados a la programación imperativa tradicional. Sin embargo, una vez adoptada, se convierte en una herramienta poderosa para escribir código más robusto, mantenible y menos propenso a errores.
Técnicas para operaciones inmutables: Object.freeze, spread y métodos no mutadores
Trabajar con inmutabilidad en JavaScript requiere conocer las herramientas y técnicas específicas que nos permiten manipular datos sin modificar los originales. Veamos las principales estrategias para implementar operaciones inmutables de manera efectiva.
Object.freeze(): Protección contra modificaciones
El método Object.freeze()
es la forma más directa de hacer un objeto inmutable en JavaScript. Este método evita que se añadan, eliminen o modifiquen propiedades de un objeto.
const settings = Object.freeze({
theme: "dark",
notifications: true,
fontSize: 16
});
// Intentar modificar el objeto congelado
settings.theme = "light"; // No produce error pero no tiene efecto
console.log(settings.theme); // Sigue siendo "dark"
// En modo estricto, intentar modificar lanza un error
"use strict";
settings.notifications = false; // TypeError: Cannot assign to read only property
Es importante entender las limitaciones de Object.freeze()
:
- Solo congela el primer nivel del objeto (congelamiento superficial)
- No afecta a objetos anidados dentro del objeto congelado
Para lograr un congelamiento profundo, necesitamos una implementación recursiva:
function deepFreeze(obj) {
// Recuperar los nombres de propiedades definidas en obj
const propNames = Object.getOwnPropertyNames(obj);
// Congelar las propiedades antes de congelar el objeto
propNames.forEach(name => {
const prop = obj[name];
// Congelar la propiedad si es un objeto
if (typeof prop === 'object' && prop !== null) {
deepFreeze(prop);
}
});
// Congelar el objeto en sí
return Object.freeze(obj);
}
const user = deepFreeze({
name: "Alex",
preferences: {
theme: "dark",
sidebar: "expanded"
}
});
// Ahora ni siquiera las propiedades anidadas pueden modificarse
user.preferences.theme = "light"; // No tendrá efecto
Operador spread (...): Clonación y extensión
El operador spread es una de las herramientas más versátiles para trabajar con inmutabilidad, permitiendo crear copias de objetos y arrays con modificaciones específicas.
Para objetos:
const user = {
id: 42,
name: "Emma",
role: "developer"
};
// Actualizar una propiedad de forma inmutable
const updatedUser = {
...user,
role: "senior developer"
};
// Añadir nuevas propiedades
const userWithDetails = {
...user,
department: "Engineering",
location: "Remote"
};
console.log(user); // Original intacto
console.log(updatedUser); // Copia con role actualizado
Para arrays:
const fruits = ["apple", "orange", "banana"];
// Añadir elementos al final
const moreFruits = [...fruits, "grape", "kiwi"];
// Añadir elementos al principio
const evenMoreFruits = ["strawberry", ...fruits];
// Combinar arrays
const vegetables = ["carrot", "broccoli"];
const foodItems = [...fruits, ...vegetables];
// Eliminar un elemento (por índice)
const index = 1; // orange
const fruitsWithoutOrange = [
...fruits.slice(0, index),
...fruits.slice(index + 1)
];
console.log(fruits); // Original intacto: ["apple", "orange", "banana"]
console.log(fruitsWithoutOrange); // ["apple", "banana"]
Métodos no mutadores de arrays
JavaScript proporciona varios métodos de array que no modifican el original, sino que devuelven un nuevo array con los cambios aplicados:
const numbers = [1, 2, 3, 4, 5];
// map(): transforma cada elemento
const doubled = numbers.map(n => n * 2);
// [2, 4, 6, 8, 10]
// filter(): selecciona elementos según una condición
const evenNumbers = numbers.filter(n => n % 2 === 0);
// [2, 4]
// reduce(): acumula valores
const sum = numbers.reduce((total, n) => total + n, 0);
// 15
// concat(): combina arrays
const moreNumbers = numbers.concat([6, 7, 8]);
// [1, 2, 3, 4, 5, 6, 7, 8]
// slice(): extrae una porción del array
const subset = numbers.slice(1, 4);
// [2, 3, 4]
Estos métodos contrastan con los métodos mutadores como push()
, pop()
, shift()
, unshift()
, splice()
, sort()
y reverse()
, que modifican el array original.
Comparativa: Operaciones mutables vs. inmutables
Veamos cómo transformar operaciones mutables comunes en sus equivalentes inmutables:
- 1. Añadir elementos a un array:
// Mutable
const addMutable = (array, item) => {
array.push(item);
return array;
};
// Inmutable
const addImmutable = (array, item) => {
return [...array, item];
};
- 2. Eliminar elementos de un array:
// Mutable
const removeMutable = (array, index) => {
array.splice(index, 1);
return array;
};
// Inmutable
const removeImmutable = (array, index) => {
return [
...array.slice(0, index),
...array.slice(index + 1)
];
};
- 3. Actualizar propiedades de un objeto:
// Mutable
const updateMutable = (object, key, value) => {
object[key] = value;
return object;
};
// Inmutable
const updateImmutable = (object, key, value) => {
return { ...object, [key]: value };
};
- 4. Ordenar un array:
// Mutable
const sortMutable = (array) => {
return array.sort();
};
// Inmutable
const sortImmutable = (array) => {
return [...array].sort();
};
Técnicas para estructuras de datos complejas
Cuando trabajamos con estructuras anidadas, mantener la inmutabilidad se vuelve más desafiante:
const project = {
id: "proj-123",
title: "Website Redesign",
client: {
id: "client-456",
name: "Acme Corp",
contact: {
email: "contact@acme.com",
phone: "555-1234"
}
},
tasks: [
{ id: 1, description: "Wireframes", completed: true },
{ id: 2, description: "Design", completed: false }
]
};
// Actualizar un valor profundamente anidado
const updatedProject = {
...project,
client: {
...project.client,
contact: {
...project.client.contact,
email: "new-contact@acme.com"
}
}
};
// Marcar una tarea como completada
const taskId = 2;
const updatedTasks = {
...project,
tasks: project.tasks.map(task =>
task.id === taskId
? { ...task, completed: true }
: task
)
};
Bibliotecas para inmutabilidad
Para proyectos más complejos, existen bibliotecas que facilitan el trabajo con datos inmutables:
- Immer: Permite escribir código que parece mutable pero produce resultados inmutables.
import produce from 'immer';
const nextState = produce(project, draft => {
// Esto parece código mutable, pero Immer lo convierte en inmutable
draft.client.contact.email = "new-contact@acme.com";
draft.tasks[1].completed = true;
});
- Immutable.js: Proporciona estructuras de datos inmutables optimizadas.
import { Map, List } from 'immutable';
const immutableProject = Map({
id: "proj-123",
tasks: List([
Map({ id: 1, completed: false }),
Map({ id: 2, completed: false })
])
});
const updated = immutableProject.setIn(['tasks', 1, 'completed'], true);
Patrones prácticos para inmutabilidad
Algunos patrones útiles al trabajar con inmutabilidad:
- Funciones de actualización: Crear funciones específicas para actualizar partes de un estado.
// Función para actualizar un elemento en un array por id
const updateItemById = (array, id, updates) => {
return array.map(item =>
item.id === id ? { ...item, ...updates } : item
);
};
// Uso
const updatedTasks = updateItemById(project.tasks, 2, { completed: true });
- Selectores: Funciones que extraen datos específicos sin modificar la fuente.
// Selector para obtener tareas completadas
const getCompletedTasks = project =>
project.tasks.filter(task => task.completed);
// Uso
const completedTasks = getCompletedTasks(project);
La inmutabilidad es un cambio de mentalidad que requiere práctica, pero estas técnicas proporcionan las herramientas necesarias para implementarla de manera efectiva en JavaScript, llevándonos un paso más cerca de la programación funcional pura.
Funciones puras: Determinismo, ausencia de efectos secundarios y transparencia referencial
Las funciones puras constituyen otro pilar fundamental de la programación funcional en JavaScript. Una función pura es aquella que cumple con dos características esenciales: siempre produce el mismo resultado para los mismos argumentos de entrada (determinismo) y no causa efectos secundarios observables fuera de su ámbito.
Determinismo: mismas entradas, mismas salidas
El determinismo es la propiedad por la cual una función, al recibir los mismos parámetros de entrada, siempre devuelve exactamente el mismo resultado, sin importar cuándo o cuántas veces se ejecute. Esta característica hace que el comportamiento de la función sea completamente predecible.
// Función pura: determinista
const sum = (a, b) => a + b;
console.log(sum(5, 3)); // Siempre devuelve 8
console.log(sum(5, 3)); // Siempre devuelve 8, sin importar cuántas veces la llamemos
Comparemos con una función no determinista:
// Función impura: no determinista
const randomSum = (a) => a + Math.random();
console.log(randomSum(5)); // Podría devolver 5.12345...
console.log(randomSum(5)); // Podría devolver 5.67890... (diferente resultado)
Ausencia de efectos secundarios
Un efecto secundario ocurre cuando una función modifica algún estado fuera de su ámbito local o tiene una interacción observable con el mundo exterior más allá de retornar un valor. Las funciones puras están libres de estos efectos.
Ejemplos de efectos secundarios incluyen:
- Modificar variables globales o parámetros recibidos
- Realizar operaciones de E/S (escribir en consola, modificar el DOM, hacer peticiones HTTP)
- Modificar el sistema de archivos
- Lanzar excepciones o errores
- Llamar a otras funciones que producen efectos secundarios
// Función impura: tiene efectos secundarios
let total = 0;
const addToTotal = (value) => {
total += value; // Modifica una variable externa
return total;
};
// Función pura: sin efectos secundarios
const add = (a, b) => a + b;
Veamos otro ejemplo más elaborado:
// Función impura: modifica el DOM (efecto secundario)
const updateHeader = (text) => {
document.getElementById('header').textContent = text;
};
// Versión más pura: devuelve lo que se debería hacer, sin hacerlo directamente
const createHeaderUpdate = (text) => {
return {
type: 'UPDATE_HEADER',
payload: { id: 'header', textContent: text }
};
};
Transparencia referencial
La transparencia referencial significa que una expresión puede ser reemplazada por su valor resultante sin cambiar el comportamiento del programa. Es una consecuencia directa del determinismo y la ausencia de efectos secundarios.
// Función con transparencia referencial
const double = (x) => x * 2;
// En cualquier parte del código, podemos reemplazar double(5) por 10
const result = double(5) + double(5);
// Es equivalente a:
const sameResult = 10 + 10;
Esta propiedad facilita enormemente el razonamiento sobre el código, ya que podemos analizar cada parte de forma aislada.
Beneficios de las funciones puras
Las funciones puras ofrecen numerosas ventajas:
- Facilidad de prueba: Al depender solo de sus entradas y no tener efectos secundarios, son extremadamente fáciles de probar.
- Memoización: Podemos almacenar en caché los resultados para entradas específicas, mejorando el rendimiento.
- Paralelización: Al no depender de estados compartidos, pueden ejecutarse en paralelo sin riesgos.
- Razonamiento local: Podemos entender y razonar sobre la función examinando solo su implementación.
- Composición: Se pueden combinar fácilmente para crear funciones más complejas.
// Ejemplo de memoización con funciones puras
const memoize = (fn) => {
const cache = {};
return (...args) => {
const key = JSON.stringify(args);
if (key in cache) {
return cache[key];
}
const result = fn(...args);
cache[key] = result;
return result;
};
};
// Función pura costosa
const fibonacci = (n) => {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
};
// Versión memoizada
const memoFibonacci = memoize(fibonacci);
console.time('Sin memoización');
fibonacci(40);
console.timeEnd('Sin memoización');
console.time('Con memoización');
memoFibonacci(40);
console.timeEnd('Con memoización');
Identificando funciones impuras
Para reconocer funciones impuras, busca estos indicadores:
- Uso de variables globales o externas a la función
- Modificación de parámetros recibidos
- Llamadas a APIs del navegador (DOM, localStorage, fetch)
- Operaciones de E/S (console.log, alert)
- Generación de valores aleatorios o dependientes del tiempo
// Ejemplos de funciones impuras
const impureFunction1 = (arr) => {
arr.push(1); // Modifica el array original
return arr;
};
const impureFunction2 = (x) => {
console.log(x); // Efecto secundario: escribe en consola
return x * 2;
};
const impureFunction3 = () => {
return new Date().toISOString(); // Depende del tiempo (no determinista)
};
Transformando funciones impuras en puras
Muchas funciones impuras pueden refactorizarse para hacerlas puras:
// Impura: modifica el array original
const addItem = (cart, item) => {
cart.push(item);
return cart;
};
// Pura: crea un nuevo array
const addItemPure = (cart, item) => {
return [...cart, item];
};
// Impura: usa y modifica estado externo
let counter = 0;
const incrementCounter = () => {
counter++;
return counter;
};
// Pura: recibe el estado y devuelve el nuevo valor
const incrementPure = (count) => count + 1;
Efectos secundarios en el mundo real
En aplicaciones reales, los efectos secundarios son inevitables (necesitamos mostrar datos en pantalla, hacer peticiones HTTP, etc.). La clave está en:
- Aislar los efectos secundarios en partes específicas del código
- Mantener la mayor parte de la lógica en funciones puras
- Empujar los efectos secundarios hacia los bordes de la aplicación
// Arquitectura con efectos secundarios aislados
const fetchUserData = async (userId) => {
// Efecto secundario aislado: petición HTTP
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
return data;
};
// Función pura: procesa los datos
const getUserDisplayName = (user) => {
return user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.username;
};
// Componente que combina ambos
const UserProfile = async ({ userId }) => {
// Efecto secundario en el borde de la aplicación
const userData = await fetchUserData(userId);
// Lógica pura en el centro
const displayName = getUserDisplayName(userData);
// Efecto secundario en el borde (renderizado)
return `<div class="profile">${displayName}</div>`;
};
Composición de funciones puras
Una de las grandes ventajas de las funciones puras es que se pueden componer fácilmente para crear funcionalidades más complejas:
// Funciones puras simples
const double = x => x * 2;
const increment = x => x + 1;
const square = x => x * x;
// Composición manual
const transformManual = x => square(increment(double(x)));
// Función helper para composición
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
// Composición con helper
const transform = compose(square, increment, double);
console.log(transform(3)); // 49 (equivalente a square(increment(double(3))))
Programación declarativaz con funciones puras
Las funciones puras facilitan un estilo de programación declarativo (especificar qué hacer, no cómo hacerlo):
// Datos de ejemplo
const products = [
{ id: 1, name: "Laptop", price: 1200, category: "Electronics" },
{ id: 2, name: "Headphones", price: 100, category: "Electronics" },
{ id: 3, name: "Coffee Mug", price: 15, category: "Kitchen" },
{ id: 4, name: "Notebook", price: 10, category: "Office" }
];
// Funciones puras para transformar datos
const filterByCategory = (products, category) =>
products.filter(product => product.category === category);
const applyDiscount = (products, percentage) =>
products.map(product => ({
...product,
price: product.price * (1 - percentage / 100)
}));
const sortByPrice = products =>
[...products].sort((a, b) => a.price - b.price);
const calculateTotal = products =>
products.reduce((sum, product) => sum + product.price, 0);
// Composición de operaciones
const discountedElectronics = compose(
products => calculateTotal(products),
products => sortByPrice(products),
products => applyDiscount(products, 10),
products => filterByCategory(products, "Electronics")
)(products);
La programación con funciones puras nos permite construir sistemas más robustos, predecibles y mantenibles. Aunque requiere un cambio de mentalidad, especialmente para desarrolladores acostumbrados a estilos imperativos, los beneficios en términos de calidad de código y facilidad de razonamiento son sustanciales.
Ejercicios de esta lección Inmutabilidad y programación funcional pura
Evalúa tus conocimientos de esta lección Inmutabilidad y programación funcional pura con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
Funciones flecha
Polimorfismo
Array
Transformación con map()
Gestor de tareas con JavaScript
Manipulación DOM
Funciones
Funciones flecha
Async / Await
Creación y uso de variables
Excepciones
Promises
Funciones cierre (closure)
Herencia
Herencia
Estructuras de control
Selección de elementos DOM
Modificación de elementos DOM
Filtrado con filter() y find()
Funciones cierre (closure)
Funciones
Mapas con Map
Reducción con reduce()
Callbacks
Manipulación DOM
Promises
Async / Await
Eventos del DOM
Async / Await
Promises
Filtrado con filter() y find()
Callbacks
Creación de clases y objetos Restaurante
Reducción con reduce()
Filtrado con filter() y find()
Reducción con reduce()
Conjuntos con Set
Herencia de clases
Eventos del DOM
Clases y objetos
Modificación de elementos DOM
Mapas con Map
Introducción a JavaScript
Funciones
Tipos de datos
Clases y objetos
Array
Conjuntos con Set
Array
Encapsulación
Clases y objetos
Uso de operadores
Uso de operadores
Estructuras de control
Excepciones
Transformación con map()
Funciones flecha
Selección de elementos DOM
Encapsulación
Mapas con Map
Creación y uso de variables
Polimorfismo
Tipos de datos
Estructuras de control
Todas las lecciones de JavaScript
Accede a todas las lecciones de JavaScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Introducción A Javascript
Introducción Y Entorno
Tipos De Datos
Sintaxis
Variables
Sintaxis
Operadores
Sintaxis
Estructuras De Control
Sintaxis
Funciones
Sintaxis
Funciones Cierre (Closure)
Sintaxis
Arrays Y Métodos
Estructuras De Datos
Conjuntos Con Set
Estructuras De Datos
Mapas Con Map
Estructuras De Datos
Funciones Flecha
Programación Funcional
Filtrado Con Filter() Y Find()
Programación Funcional
Transformación Con Map()
Programación Funcional
Reducción Con Reduce()
Programación Funcional
Clases Y Objetos
Programación Orientada A Objetos
Excepciones
Programación Orientada A Objetos
Encapsulación
Programación Orientada A Objetos
Herencia
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
Manipulación Dom
Dom
Selección De Elementos Dom
Dom
Modificación De Elementos Dom
Dom
Eventos Del Dom
Dom
Callbacks
Programación Asíncrona
Promises
Programación Asíncrona
Async / Await
Programación Asíncrona
Certificados de superación de JavaScript
Supera todos los ejercicios de programación del curso de JavaScript y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender el concepto de inmutabilidad y cómo se aplica en JavaScript.
- Identificar las ventajas de utilizar inmutabilidad en el desarrollo de software.
- Implementar técnicas como Object.freeze, spread operator y métodos no mutadores.
- Diferenciar entre funciones puras e impuras y sus efectos en el código.
- Adoptar buenas prácticas para trabajar con estructuras de datos inmutables.
- Aprovechar bibliotecas especializadas para mejorar la inmutabilidad.