TypeScript
Tutorial TypeScript: Conceptos básicos e inmutabilidad
Aprende los fundamentos de programación funcional e inmutabilidad en TypeScript para escribir código predecible y mantenible.
Aprende TypeScript y certifícateFundamentos de programación funcional
A diferencia de la programación imperativa, donde nos centramos en describir cómo realizar una tarea mediante una secuencia de instrucciones que modifican el estado, la programación funcional se enfoca en qué transformaciones aplicar a los datos. Este cambio de perspectiva nos permite razonar sobre nuestro código de manera más clara y reducir la complejidad.
Principios fundamentales
La programación funcional se basa en varios principios clave:
- Funciones como ciudadanos de primera clase: Las funciones pueden asignarse a variables, pasarse como argumentos y devolverse como valores.
// Función asignada a una variable
const greet = (name: string): string => `Hello, ${name}!`;
// Función pasada como argumento
const executeOperation = (operation: (x: number, y: number) => number, a: number, b: number): number => {
return operation(a, b);
};
const add = (x: number, y: number): number => x + y;
const result = executeOperation(add, 5, 3); // 8
- Declarativo vs imperativo: El código funcional describe qué debe hacerse, no cómo hacerlo.
// Enfoque imperativo
const getEvenNumbersImperative = (numbers: number[]): number[] => {
const result: number[] = [];
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
result.push(numbers[i]);
}
}
return result;
};
// Enfoque declarativo (funcional)
const getEvenNumbersFunctional = (numbers: number[]): number[] =>
numbers.filter(num => num % 2 === 0);
Inmutabilidad: Los datos no se modifican una vez creados, sino que se crean nuevas estructuras con los cambios aplicados (profundizaremos en esto en la siguiente sección).
Funciones puras: Las funciones no tienen efectos secundarios y siempre devuelven el mismo resultado para los mismos argumentos (se tratará en detalle en otra lección).
Expresiones vs declaraciones
En programación funcional, preferimos las expresiones sobre las declaraciones. Una expresión produce un valor, mientras que una declaración realiza una acción.
// Declaración (statement)
let result = 0;
if (x > 10) {
result = x * 2;
} else {
result = x / 2;
}
// Expresión (expression)
const result = x > 10 ? x * 2 : x / 2;
Las expresiones son más concisas y facilitan la composición de operaciones.
Funciones anónimas y arrow functions
TypeScript nos permite crear funciones anónimas y arrow functions, que son especialmente útiles en programación funcional:
// Función anónima tradicional
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(function(n: number): number {
return n * 2;
});
// Arrow function (más concisa)
const tripled = numbers.map((n: number): number => n * 3);
// Con inferencia de tipos (aún más concisa)
const quadrupled = numbers.map(n => n * 4);
Las arrow functions son particularmente útiles porque mantienen el contexto léxico de this
, lo que evita confusiones comunes en callbacks.
Recursión en lugar de iteración
La programación funcional favorece la recursión sobre los bucles tradicionales para procesar colecciones de datos:
// Enfoque iterativo
const factorial = (n: number): number => {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
};
// Enfoque recursivo
const factorialRecursive = (n: number): number => {
return n <= 1 ? 1 : n * factorialRecursive(n - 1);
};
Sin embargo, es importante tener en cuenta que TypeScript/JavaScript no optimiza la recursión de cola por defecto, lo que puede llevar a errores de desbordamiento de pila con valores grandes.
Funciones de orden superior
Las funciones de orden superior son aquellas que toman otras funciones como argumentos o devuelven funciones como resultado. Son fundamentales en programación funcional:
// Función que devuelve otra función
const multiply = (factor: number): (x: number) => number => {
return (x: number): number => x * factor;
};
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
Este patrón, conocido como currificación, permite crear funciones especializadas a partir de funciones más generales.
Transformación de datos con operaciones en cadena
Un patrón común en programación funcional es encadenar operaciones para transformar datos paso a paso:
interface User {
id: number;
name: string;
age: number;
active: boolean;
}
const users: User[] = [
{ id: 1, name: "Alice", age: 25, active: true },
{ id: 2, name: "Bob", age: 17, active: false },
{ id: 3, name: "Charlie", age: 30, active: true },
{ id: 4, name: "Diana", age: 22, active: true },
];
// Obtener nombres de usuarios activos mayores de edad
const activeAdultNames = users
.filter(user => user.active)
.filter(user => user.age >= 18)
.map(user => user.name);
console.log(activeAdultNames); // ["Alice", "Charlie", "Diana"]
Este enfoque permite expresar transformaciones complejas de manera clara y legible.
Ventajas de la programación funcional
- Código más predecible: Al evitar estados mutables y efectos secundarios, el comportamiento del código es más fácil de predecir.
- Facilidad para testing: Las funciones puras son más fáciles de probar, ya que no dependen de un estado externo.
- Concurrencia: El código funcional es naturalmente más adecuado para entornos concurrentes, ya que no hay estado compartido que pueda causar condiciones de carrera.
- Reutilización: Las funciones pequeñas y específicas son más fáciles de reutilizar en diferentes contextos.
- Razonamiento: Es más fácil razonar sobre el código cuando las funciones tienen un comportamiento consistente y predecible.
La programación funcional no es un enfoque de "todo o nada". En TypeScript, podemos adoptar gradualmente técnicas funcionales mientras mantenemos la compatibilidad con código existente y aprovechamos las características orientadas a objetos cuando sea apropiado.
Concepto y beneficios de inmutabilidad
La inmutabilidad es uno de los pilares fundamentales de la programación funcional y se refiere a la idea de que los datos, una vez creados, no deben modificarse. En lugar de alterar estructuras de datos existentes, creamos nuevas versiones que incorporan los cambios deseados. Este enfoque, aunque puede parecer contraintuitivo al principio, ofrece numerosas ventajas para el desarrollo de software moderno.
En TypeScript, podemos implementar patrones inmutables que nos ayudan a escribir código más predecible y menos propenso a errores. Veamos en qué consiste exactamente la inmutabilidad y por qué es tan valiosa.
¿Qué significa realmente la inmutabilidad?
La inmutabilidad implica que los valores no cambian después de su creación. Cuando necesitamos representar un cambio, creamos una nueva estructura de datos en lugar de modificar la existente:
// Enfoque mutable (modificando el original)
const addItemMutable = (cart: string[]): void => {
cart.push("New Item"); // Modifica el array original
};
// Enfoque inmutable (creando una nueva versión)
const addItemImmutable = (cart: string[]): string[] => {
return [...cart, "New Item"]; // Devuelve un nuevo array
};
const myCart = ["Book", "Laptop"];
const updatedCart = addItemImmutable(myCart);
console.log(myCart); // ["Book", "Laptop"] - no cambia
console.log(updatedCart); // ["Book", "Laptop", "New Item"]
En el ejemplo anterior, la función inmutable no modifica el array original, sino que crea y devuelve uno nuevo con el elemento adicional.
Beneficios clave de la inmutabilidad
1. Predictibilidad y razonamiento simplificado
Cuando los datos son inmutables, su comportamiento es predecible. No tenemos que preocuparnos por efectos colaterales inesperados donde una parte del código modifica datos que otra parte está utilizando:
// Con datos mutables, esto podría ser peligroso:
const processUserData = (user: User): void => {
// Si otra función modifica user mientras procesamos...
calculateMetrics(user);
updateUI(user);
};
// Con inmutabilidad, cada función trabaja con su propia copia:
const processUserDataSafely = (user: User): User => {
const userWithMetrics = calculateMetrics(user);
return prepareForUI(userWithMetrics);
};
2. Facilita la programación concurrente
En entornos multihilo o con operaciones asíncronas, la inmutabilidad elimina muchos problemas de concurrencia, ya que no hay datos compartidos que puedan ser modificados simultáneamente:
// Con datos inmutables, no hay riesgo de condiciones de carrera
const processInParallel = async (data: ReadonlyArray<number>): Promise<number[]> => {
// Podemos procesar en paralelo sin preocuparnos por modificaciones simultáneas
const results = await Promise.all(
data.map(async item => await someAsyncProcessing(item))
);
return results;
};
3. Historial de cambios y viajes en el tiempo
La inmutabilidad facilita implementar funcionalidades como deshacer/rehacer o depuración de estados, ya que podemos guardar cada versión de nuestros datos:
interface AppState {
readonly counter: number;
readonly todos: ReadonlyArray<Todo>;
}
type Action = { type: 'INCREMENT' } | { type: 'ADD_TODO', payload: Todo };
// Cada acción produce un nuevo estado sin modificar el anterior
const reducer = (state: AppState, action: Action): AppState => {
switch (action.type) {
case 'INCREMENT':
return { ...state, counter: state.counter + 1 };
case 'ADD_TODO':
return { ...state, todos: [...state.todos, action.payload] };
default:
return state;
}
};
// Podemos almacenar cada estado para implementar "viaje en el tiempo"
const stateHistory: AppState[] = [];
4. Mejora la testabilidad
Las funciones que operan con datos inmutables son más fáciles de probar, ya que su comportamiento es consistente y no depende de un estado global que pueda cambiar:
// Función pura que trabaja con inmutabilidad
const calculateTotal = (items: ReadonlyArray<{price: number}>): number => {
return items.reduce((sum, item) => sum + item.price, 0);
};
// Test sencillo y predecible
test('calculateTotal returns correct sum', () => {
const testItems = [{ price: 10 }, { price: 20 }];
expect(calculateTotal(testItems)).toBe(30);
// No hay que preocuparse de que testItems haya sido modificado
});
5. Rendimiento optimizado en ciertos escenarios
Aunque crear nuevas copias puede parecer ineficiente, los frameworks modernos como React utilizan la inmutabilidad para optimizar el rendimiento mediante comparaciones de referencia rápidas:
// Con inmutabilidad, podemos detectar cambios con una simple comparación
const hasChanged = (prevState: any, nextState: any): boolean => {
return prevState !== nextState;
};
// Si el objeto es el mismo (misma referencia), sabemos que no ha cambiado
// Esto permite optimizaciones como memoización o renderizado condicional
Implementando inmutabilidad en TypeScript
TypeScript ofrece varias herramientas para trabajar con datos inmutables:
Uso de readonly y ReadonlyArray
El modificador readonly
y el tipo ReadonlyArray<T>
nos permiten declarar explícitamente la intención de inmutabilidad:
// Propiedades inmutables en interfaces
interface User {
readonly id: number;
readonly name: string;
age: number; // Esta propiedad puede cambiar
}
// Arrays inmutables
function processData(data: ReadonlyArray<number>): number {
// No podemos usar métodos mutables como push, pop, etc.
// data.push(5); // Error de compilación
return data.reduce((sum, val) => sum + val, 0);
}
Objetos inmutables con Object.freeze
Para una inmutabilidad más estricta en tiempo de ejecución, podemos usar Object.freeze
:
interface Config {
apiUrl: string;
timeout: number;
}
const config: Config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 3000
});
// Esto lanzará un error en modo estricto o fallará silenciosamente
// config.timeout = 5000;
Patrones de actualización inmutable
Para actualizar estructuras de datos de forma inmutable, utilizamos técnicas como el operador spread:
// Actualización inmutable de objetos
interface Task {
id: number;
title: string;
completed: boolean;
}
const task: Task = { id: 1, title: "Learn TypeScript", completed: false };
// Crear una nueva versión con completed = true
const completedTask: Task = { ...task, completed: true };
// Actualización inmutable de arrays
const tasks: Task[] = [
{ id: 1, title: "Learn TypeScript", completed: false },
{ id: 2, title: "Practice immutability", completed: false }
];
// Añadir una tarea
const tasksWithNewTask: Task[] = [...tasks, { id: 3, title: "Master TypeScript", completed: false }];
// Actualizar una tarea específica
const updatedTasks: Task[] = tasks.map(t =>
t.id === 2 ? { ...t, completed: true } : t
);
// Eliminar una tarea
const filteredTasks: Task[] = tasks.filter(t => t.id !== 1);
Inmutabilidad vs. rendimiento
Una preocupación común sobre la inmutabilidad es su impacto en el rendimiento, especialmente con estructuras de datos grandes. Algunas consideraciones:
- Para objetos pequeños, el impacto es generalmente insignificante y compensado por los beneficios.
- Para estructuras grandes, existen bibliotecas como Immutable.js o immer que implementan estructuras de datos inmutables eficientes:
import produce from 'immer';
interface State {
users: User[];
settings: Settings;
}
const currentState: State = {/* ... */};
// Immer permite escribir código que parece mutable
// pero produce resultados inmutables
const nextState = produce(currentState, draft => {
// Esto parece código mutable, pero immer crea una nueva estructura
draft.users.push(newUser);
draft.settings.darkMode = true;
});
// currentState no se modifica, nextState es una nueva estructura
Inmutabilidad en la arquitectura de aplicaciones
La inmutabilidad se ha convertido en un principio fundamental en arquitecturas modernas como Redux, Flux o aplicaciones basadas en máquinas de estado:
// Ejemplo simplificado de un store tipo Redux
type State = { count: number };
type Action = { type: 'INCREMENT' } | { type: 'DECREMENT' };
const initialState: State = { count: 0 };
function reducer(state: State = initialState, action: Action): State {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
default:
return state;
}
}
// Cada acción produce un nuevo estado, nunca modifica el existente
La inmutabilidad no es solo una técnica de programación, sino un enfoque conceptual que cambia nuestra forma de pensar sobre los datos y las transformaciones. Al adoptar este principio, construimos aplicaciones más robustas, mantenibles y predecibles.
Operaciones inmutables con arrays y objetos
La inmutabilidad es un concepto fundamental en programación funcional, pero su aplicación práctica requiere dominar técnicas específicas para trabajar con estructuras de datos como arrays y objetos sin modificarlos. En TypeScript, disponemos de varias herramientas y patrones que nos permiten realizar operaciones manteniendo la inmutabilidad.
Cuando trabajamos con estructuras de datos inmutables, cada operación que normalmente modificaría la estructura original debe reemplazarse por una versión que cree una nueva estructura con los cambios aplicados. Veamos cómo implementar estas operaciones de forma inmutable.
Operaciones inmutables con arrays
Los arrays son estructuras de datos fundamentales que solemos manipular constantemente. TypeScript nos ofrece varias formas de trabajar con ellos de manera inmutable.
Añadir elementos
Para añadir elementos a un array de forma inmutable, podemos usar el operador spread (...
) o métodos como concat()
:
// Array original
const fruits: string[] = ["apple", "banana", "orange"];
// Añadir al final (en lugar de push)
const newFruits1 = [...fruits, "strawberry"];
// Añadir al principio (en lugar de unshift)
const newFruits2 = ["strawberry", ...fruits];
// Añadir en una posición específica
const insertAt = 2;
const newFruits3 = [
...fruits.slice(0, insertAt),
"strawberry",
...fruits.slice(insertAt)
];
// Usando concat (alternativa al spread)
const newFruits4 = fruits.concat("strawberry");
Eliminar elementos
Para eliminar elementos sin mutar el array original:
const numbers = [1, 2, 3, 4, 5];
// Eliminar el último elemento (en lugar de pop)
const withoutLast = numbers.slice(0, -1);
// Eliminar el primer elemento (en lugar de shift)
const withoutFirst = numbers.slice(1);
// Eliminar un elemento por índice
const indexToRemove = 2;
const withoutIndex = [
...numbers.slice(0, indexToRemove),
...numbers.slice(indexToRemove + 1)
];
// Eliminar elementos que cumplan una condición
const onlyEvens = numbers.filter(n => n % 2 === 0);
Actualizar elementos
Para actualizar elementos específicos:
const tasks = [
{ id: 1, text: "Learn TypeScript", completed: false },
{ id: 2, text: "Practice immutability", completed: false },
{ id: 3, text: "Build a project", completed: false }
];
// Actualizar un elemento por índice
const indexToUpdate = 1;
const tasksWithUpdated = [
...tasks.slice(0, indexToUpdate),
{ ...tasks[indexToUpdate], completed: true },
...tasks.slice(indexToUpdate + 1)
];
// Actualizar usando map (más elegante cuando actualizamos por condición)
const updatedTasks = tasks.map(task =>
task.id === 2 ? { ...task, completed: true } : task
);
Transformar arrays
Para transformar todos los elementos de un array:
const prices = [10, 20, 30, 40];
// Transformar cada elemento (en lugar de un bucle con mutación)
const discountedPrices = prices.map(price => price * 0.9);
// Filtrar elementos
const affordablePrices = prices.filter(price => price < 30);
// Acumular valores
const totalPrice = prices.reduce((sum, price) => sum + price, 0);
// Combinar operaciones en cadena
const discountedTotal = prices
.filter(price => price > 20)
.map(price => price * 0.8)
.reduce((sum, price) => sum + price, 0);
Ordenar arrays de forma inmutable
El método sort()
muta el array original, así que necesitamos crear una copia:
const names = ["Charlie", "Alice", "Bob", "David"];
// Ordenar de forma inmutable (sort() muta el array original)
const sortedNames = [...names].sort();
// Ordenar objetos
interface Person {
name: string;
age: number;
}
const people: Person[] = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
{ name: "Charlie", age: 35 }
];
const sortedByAge = [...people].sort((a, b) => a.age - b.age);
Operaciones inmutables con objetos
Los objetos en TypeScript también pueden manipularse de forma inmutable utilizando técnicas específicas.
Añadir o actualizar propiedades
Para añadir o actualizar propiedades sin mutar el objeto original:
interface User {
id: number;
name: string;
email: string;
preferences?: {
darkMode: boolean;
notifications: boolean;
};
}
const user: User = {
id: 1,
name: "John Doe",
email: "john@example.com"
};
// Añadir o actualizar propiedades superficiales
const updatedUser = {
...user,
email: "john.doe@example.com",
lastLogin: new Date()
};
// Añadir propiedades anidadas
const userWithPreferences = {
...user,
preferences: {
darkMode: true,
notifications: false
}
};
Actualizar propiedades anidadas
La actualización inmutable de propiedades anidadas requiere especial atención:
const userWithPrefs: User = {
id: 2,
name: "Jane Smith",
email: "jane@example.com",
preferences: {
darkMode: false,
notifications: true
}
};
// Actualizar una propiedad anidada
const userWithDarkMode = {
...userWithPrefs,
preferences: {
...userWithPrefs.preferences,
darkMode: true
}
};
// Para estructuras más profundas, el anidamiento puede volverse tedioso
const complexObject = {
a: {
b: {
c: {
d: 1
}
}
}
};
// Actualizar d de forma inmutable
const updatedComplex = {
...complexObject,
a: {
...complexObject.a,
b: {
...complexObject.a.b,
c: {
...complexObject.a.b.c,
d: 2
}
}
}
};
Eliminar propiedades
Para eliminar propiedades de un objeto de forma inmutable, podemos usar la desestructuración:
const product = {
id: 123,
name: "Laptop",
price: 999,
temporary: true,
metadata: {
stock: 5,
category: "electronics"
}
};
// Eliminar una propiedad
const { temporary, ...productWithoutTemporary } = product;
// Eliminar múltiples propiedades
const { metadata, price, ...basicProduct } = product;
// No podemos eliminar propiedades anidadas directamente con desestructuración
// Para eso necesitamos combinar técnicas
const productWithoutStock = {
...product,
metadata: {
...product.metadata,
stock: undefined // O podemos crear un nuevo objeto sin esa propiedad
}
};
// Alternativa usando una función auxiliar para omitir propiedades
function omit<T extends object, K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> {
const result = { ...obj };
keys.forEach(key => delete result[key]);
return result as Omit<T, K>;
}
const cleanProduct = omit(product, ["temporary", "metadata"]);
Patrones avanzados para inmutabilidad
A medida que nuestras estructuras de datos se vuelven más complejas, necesitamos técnicas más sofisticadas para mantener la inmutabilidad.
Actualizaciones con rutas de acceso
Para actualizar propiedades profundamente anidadas, podemos crear funciones auxiliares:
// Función para actualizar una propiedad en cualquier nivel de anidamiento
function updateAt<T>(
obj: T,
path: string[],
value: any
): T {
if (path.length === 0) return value;
const [head, ...tail] = path;
return {
...obj,
[head]: updateAt(
(obj as any)[head] || {},
tail,
value
)
};
}
const deepObject = {
user: {
profile: {
address: {
city: "Old City",
zipCode: "12345"
}
}
}
};
// Actualizar city de forma inmutable
const updatedCity = updateAt(
deepObject,
["user", "profile", "address", "city"],
"New City"
);
Lentes funcionales
Los lentes son una abstracción funcional que facilita trabajar con estructuras anidadas:
// Implementación simplificada de lentes
interface Lens<S, A> {
get: (s: S) => A;
set: (a: A, s: S) => S;
}
// Crear un lens para una propiedad
function prop<S, K extends keyof S>(key: K): Lens<S, S[K]> {
return {
get: (s: S) => s[key],
set: (a: S[K], s: S) => ({
...s,
[key]: a
})
};
}
// Componer lenses para acceder a propiedades anidadas
function compose<S, A, B>(
ab: Lens<A, B>,
sa: Lens<S, A>
): Lens<S, B> {
return {
get: (s: S) => ab.get(sa.get(s)),
set: (b: B, s: S) => sa.set(
ab.set(b, sa.get(s)),
s
)
};
}
// Ejemplo de uso
const userLens = prop<typeof deepObject, 'user'>('user');
const profileLens = prop<typeof deepObject.user, 'profile'>('profile');
const addressLens = prop<typeof deepObject.user.profile, 'address'>('address');
const cityLens = prop<typeof deepObject.user.profile.address, 'city'>('city');
// Componer lenses para llegar a city
const userProfileAddressCityLens = compose(
cityLens,
compose(
addressLens,
compose(profileLens, userLens)
)
);
// Obtener y actualizar city
const city = userProfileAddressCityLens.get(deepObject);
const objectWithNewCity = userProfileAddressCityLens.set("Another City", deepObject);
Optimizaciones para estructuras inmutables
Cuando trabajamos con estructuras de datos grandes, la creación constante de nuevas copias puede afectar al rendimiento. Algunas estrategias para mitigar esto:
Memoización
Podemos usar memoización para evitar recalcular resultados:
function memoize<T, R>(fn: (arg: T) => R): (arg: T) => R {
const cache = new Map<T, R>();
return (arg: T): R => {
if (cache.has(arg)) {
return cache.get(arg)!;
}
const result = fn(arg);
cache.set(arg, result);
return result;
};
}
// Ejemplo: transformación costosa memoizada
const expensiveTransformation = memoize((data: number[]) => {
console.log("Computing...");
return data.map(x => x * x).filter(x => x > 100);
});
// Solo se calcula la primera vez
const result1 = expensiveTransformation([1, 2, 10, 20]);
// Se obtiene del caché
const result2 = expensiveTransformation([1, 2, 10, 20]);
Estructuras de datos persistentes
Para aplicaciones con requisitos de rendimiento estrictos, podemos usar bibliotecas que implementan estructuras de datos persistentes:
import { List, Map } from 'immutable';
// Crear estructuras inmutables eficientes
const immutableList = List([1, 2, 3, 4]);
const newList = immutableList.push(5); // No muta, crea una nueva versión eficientemente
const immutableMap = Map({ a: 1, b: 2 });
const newMap = immutableMap.set('c', 3);
// Las estructuras originales permanecen intactas
console.log(immutableList.toArray()); // [1, 2, 3, 4]
console.log(newList.toArray()); // [1, 2, 3, 4, 5]
Aplicación práctica: gestión de estado inmutable
Veamos un ejemplo práctico de cómo aplicar estas técnicas en la gestión de estado de una aplicación:
// Definición del estado
interface AppState {
users: User[];
selectedUserId: number | null;
isLoading: boolean;
error: string | null;
}
// Acciones que pueden modificar el estado
type Action =
| { type: 'FETCH_USERS_START' }
| { type: 'FETCH_USERS_SUCCESS', payload: User[] }
| { type: 'FETCH_USERS_ERROR', payload: string }
| { type: 'SELECT_USER', payload: number }
| { type: 'UPDATE_USER', payload: User }
| { type: 'DELETE_USER', payload: number };
// Estado inicial
const initialState: AppState = {
users: [],
selectedUserId: null,
isLoading: false,
error: null
};
// Reducer que maneja las transiciones de estado de forma inmutable
function reducer(state: AppState = initialState, action: Action): AppState {
switch (action.type) {
case 'FETCH_USERS_START':
return {
...state,
isLoading: true,
error: null
};
case 'FETCH_USERS_SUCCESS':
return {
...state,
users: action.payload,
isLoading: false
};
case 'FETCH_USERS_ERROR':
return {
...state,
error: action.payload,
isLoading: false
};
case 'SELECT_USER':
return {
...state,
selectedUserId: action.payload
};
case 'UPDATE_USER':
return {
...state,
users: state.users.map(user =>
user.id === action.payload.id ? action.payload : user
)
};
case 'DELETE_USER':
return {
...state,
users: state.users.filter(user => user.id !== action.payload),
selectedUserId: state.selectedUserId === action.payload ? null : state.selectedUserId
};
default:
return state;
}
}
Este patrón de gestión de estado inmutable es la base de bibliotecas populares como Redux y proporciona una forma predecible y trazable de manejar los cambios en la aplicación.
Las operaciones inmutables con arrays y objetos son fundamentales para implementar correctamente la programación funcional en TypeScript. Aunque requieren un cambio en la forma de pensar sobre las transformaciones de datos, los beneficios en términos de predictibilidad, depuración y mantenimiento del código compensan ampliamente el esfuerzo inicial de aprendizaje.
Uso de 'const' vs inmutabilidad real
La palabra clave const
en TypeScript es una de las formas más comunes de declarar variables, pero existe una confusión frecuente entre el uso de const
y el concepto de inmutabilidad. Aunque están relacionados, representan ideas fundamentalmente diferentes que es crucial entender para escribir código funcional efectivo.
Entendiendo la diferencia fundamental
Cuando declaramos una variable con const
, lo que realmente estamos haciendo es crear una referencia inmutable, no un valor inmutable:
// La referencia 'user' no puede ser reasignada
const user = { name: "Alice", age: 30 };
// Esto causaría un error de compilación
// user = { name: "Bob", age: 25 };
// Pero podemos modificar las propiedades del objeto
user.age = 31; // Esto es válido
En el ejemplo anterior, aunque no podemos reasignar la variable user
a un nuevo objeto, el contenido del objeto en sí sigue siendo mutable. Esta distinción es fundamental para entender por qué const
por sí solo no garantiza la inmutabilidad real.
Inmutabilidad a nivel de referencia vs. inmutabilidad profunda
Podemos distinguir entre dos niveles de inmutabilidad:
- Inmutabilidad de referencia: La variable no puede apuntar a un nuevo valor (lo que proporciona
const
) - Inmutabilidad profunda: El valor en sí no puede ser modificado internamente
// Inmutabilidad de referencia (solo con const)
const numbers = [1, 2, 3];
numbers.push(4); // Permitido, modifica el array original
// Intento de inmutabilidad profunda
const frozenNumbers = Object.freeze([1, 2, 3]);
// frozenNumbers.push(4); // Error en tiempo de ejecución en modo estricto
El método Object.freeze()
nos acerca más a la inmutabilidad real, pero tiene limitaciones importantes:
// Object.freeze solo es superficial
const nestedObject = Object.freeze({
name: "Alice",
address: {
city: "Wonderland",
zipCode: "12345"
}
});
// Esto no funciona
// nestedObject.name = "Bob"; // Error en modo estricto
// Pero esto sí funciona (las propiedades anidadas siguen siendo mutables)
nestedObject.address.city = "Looking Glass"; // Permitido
Implementando inmutabilidad real en TypeScript
Para lograr una inmutabilidad verdadera, necesitamos combinar varias técnicas:
1. Uso de readonly y ReadonlyArray
TypeScript proporciona modificadores que ayudan a garantizar la inmutabilidad a nivel de tipo:
// Declaración de tipos inmutables
interface ImmutableUser {
readonly name: string;
readonly age: number;
readonly preferences: ReadonlyArray<string>;
}
const user: ImmutableUser = {
name: "Alice",
age: 30,
preferences: ["dark mode", "notifications"]
};
// Estos intentos de modificación causarían errores de compilación
// user.age = 31;
// user.preferences.push("sound");
El modificador readonly
es especialmente útil para interfaces y tipos, mientras que ReadonlyArray<T>
proporciona un tipo de array donde los métodos mutables como push
y pop
no están disponibles.
2. Congelación profunda de objetos
Para una inmutabilidad más robusta en tiempo de ejecución, podemos implementar una función de congelación profunda:
function deepFreeze<T>(obj: T): Readonly<T> {
// Obtener los nombres de las propiedades definidas en el objeto
const propNames = Object.getOwnPropertyNames(obj as Object);
// Congelar las propiedades antes de congelar el objeto principal
for (const name of propNames) {
const value = (obj as any)[name];
// Congelar recursivamente si la propiedad es un objeto no nulo
if (value && typeof value === "object" && !Object.isFrozen(value)) {
deepFreeze(value);
}
}
// Finalmente congelar el objeto principal
return Object.freeze(obj);
}
// Uso
const config = deepFreeze({
api: {
url: "https://api.example.com",
timeout: 3000,
retries: {
max: 3,
delay: 1000
}
},
features: ["darkMode", "analytics"]
});
// Ahora ninguna propiedad, incluso las anidadas, puede ser modificada
// config.api.retries.max = 5; // Error en modo estricto
Esta función recorre recursivamente todas las propiedades del objeto y las congela, proporcionando una inmutabilidad más profunda que Object.freeze()
por sí solo.
Patrones para trabajar con inmutabilidad real
Una vez que entendemos la diferencia entre const
y la inmutabilidad real, podemos adoptar patrones que nos ayuden a mantener la inmutabilidad en nuestro código:
Patrón 1: Transformaciones inmutables
En lugar de modificar objetos, creamos nuevas versiones con los cambios aplicados:
interface User {
name: string;
email: string;
settings: {
notifications: boolean;
theme: string;
};
}
// Función que actualiza un usuario de forma inmutable
function updateUserSettings(
user: Readonly<User>,
newSettings: Partial<User['settings']>
): User {
return {
...user,
settings: {
...user.settings,
...newSettings
}
};
}
const originalUser: User = {
name: "John",
email: "john@example.com",
settings: {
notifications: true,
theme: "light"
}
};
// Crear una nueva versión con configuraciones actualizadas
const updatedUser = updateUserSettings(originalUser, { theme: "dark" });
// El usuario original permanece intacto
console.log(originalUser.settings.theme); // "light"
console.log(updatedUser.settings.theme); // "dark"
Patrón 2: Funciones de actualización con lentes
Para estructuras más complejas, podemos usar el patrón de lentes para actualizar propiedades anidadas:
// Definición simplificada de un lente
type Lens<S, A> = {
get: (s: S) => A;
set: (a: A, s: S) => S;
};
// Crear un lente para una propiedad específica
function lens<S, K extends keyof S>(prop: K): Lens<S, S[K]> {
return {
get: (s: S) => s[prop],
set: (a: S[K], s: S) => ({...s, [prop]: a})
};
}
// Componer lentes para acceder a propiedades anidadas
function compose<S, A, B>(
ab: Lens<A, B>,
sa: Lens<S, A>
): Lens<S, B> {
return {
get: (s: S) => ab.get(sa.get(s)),
set: (b: B, s: S) => sa.set(ab.set(b, sa.get(s)), s)
};
}
// Ejemplo de uso con un objeto anidado
interface AppState {
user: {
profile: {
preferences: {
darkMode: boolean;
};
};
};
}
const state: AppState = {
user: {
profile: {
preferences: {
darkMode: false
}
}
}
};
// Crear lentes para cada nivel
const userLens = lens<AppState, 'user'>('user');
const profileLens = lens<AppState['user'], 'profile'>('profile');
const preferencesLens = lens<AppState['user']['profile'], 'preferences'>('preferences');
const darkModeLens = lens<AppState['user']['profile']['preferences'], 'darkMode'>('darkMode');
// Componer lentes para llegar a darkMode
const darkModePathLens = compose(
darkModeLens,
compose(preferencesLens, compose(profileLens, userLens))
);
// Actualizar darkMode de forma inmutable
const newState = darkModePathLens.set(true, state);
console.log(state.user.profile.preferences.darkMode); // false
console.log(newState.user.profile.preferences.darkMode); // true
Bibliotecas para inmutabilidad real
Aunque podemos implementar inmutabilidad manualmente, existen bibliotecas especializadas que facilitan este trabajo:
Immutable.js
Proporciona estructuras de datos persistentes e inmutables optimizadas:
import { Map, List } from 'immutable';
// Crear estructuras inmutables
const userMap = Map({
name: "Alice",
skills: List(["TypeScript", "React"])
});
// Las operaciones devuelven nuevas instancias
const updatedUser = userMap.set('name', 'Bob')
.update('skills', skills => skills.push('Node.js'));
console.log(userMap.get('name')); // "Alice"
console.log(updatedUser.get('name')); // "Bob"
console.log(updatedUser.get('skills').toJS()); // ["TypeScript", "React", "Node.js"]
Immer
Permite escribir código que parece mutable pero produce resultados inmutables:
import produce from 'immer';
interface Todo {
id: number;
text: string;
completed: boolean;
}
const todos: Todo[] = [
{ id: 1, text: "Learn TypeScript", completed: false },
{ id: 2, text: "Build an app", completed: false }
];
// Actualizar de forma "mutable" pero con resultado inmutable
const newTodos = produce(todos, draft => {
// Esto parece código mutable
draft[0].completed = true;
draft.push({ id: 3, text: "Deploy to production", completed: false });
});
// Los todos originales no cambian
console.log(todos.length); // 2
console.log(todos[0].completed); // false
// La nueva versión contiene los cambios
console.log(newTodos.length); // 3
console.log(newTodos[0].completed); // true
Rendimiento e inmutabilidad
Una preocupación común sobre la inmutabilidad real es su impacto en el rendimiento:
// Enfoque mutable (potencialmente más rápido para operaciones simples)
function incrementCounterMutable(counter: { value: number }): void {
counter.value += 1; // Modifica directamente
}
// Enfoque inmutable (crea un nuevo objeto)
function incrementCounterImmutable(counter: { value: number }): { value: number } {
return { value: counter.value + 1 };
}
// Comparación de rendimiento
const iterations = 1000000;
const mutableCounter = { value: 0 };
const immutableCounter = { value: 0 };
console.time('mutable');
for (let i = 0; i < iterations; i++) {
incrementCounterMutable(mutableCounter);
}
console.timeEnd('mutable');
console.time('immutable');
let counter = immutableCounter;
for (let i = 0; i < iterations; i++) {
counter = incrementCounterImmutable(counter);
}
console.timeEnd('immutable');
Sin embargo, las bibliotecas de inmutabilidad optimizadas utilizan técnicas como estructuras de datos persistentes y compartición estructural para minimizar el impacto en el rendimiento:
import { Map } from 'immutable';
// Las estructuras inmutables optimizadas reutilizan partes no modificadas
const originalMap = Map({
user: {
name: "Alice",
age: 30
},
settings: {
theme: "light",
notifications: true
}
});
// Solo se crea una nueva versión de la parte modificada
const updatedMap = originalMap.setIn(['user', 'age'], 31);
// La parte no modificada (settings) se comparte entre ambas estructuras
console.log(originalMap.get('settings') === updatedMap.get('settings')); // true
Inmutabilidad en el ecosistema TypeScript
La inmutabilidad real se ha convertido en un principio fundamental en muchas bibliotecas y frameworks modernos:
React y la inmutabilidad
React depende de la inmutabilidad para optimizar el renderizado:
// Componente React que utiliza inmutabilidad para optimizar renderizados
import React, { useState } from 'react';
interface Todo {
id: number;
text: string;
completed: boolean;
}
const TodoList: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const addTodo = (text: string) => {
// Crear una nueva lista en lugar de modificar la existente
setTodos([...todos, {
id: Date.now(),
text,
completed: false
}]);
};
const toggleTodo = (id: number) => {
// Actualizar de forma inmutable
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// El resto del componente...
};
Redux y el estado inmutable
Redux requiere reducers puros que trabajen con inmutabilidad:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = {
value: 0
};
// Redux Toolkit utiliza Immer internamente para manejar la inmutabilidad
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: state => {
// Parece mutable, pero Redux Toolkit garantiza inmutabilidad
state.value += 1;
},
decrement: state => {
state.value -= 1;
},
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload;
}
}
});
Conclusión práctica: cuándo usar cada enfoque
- Usa
**const**
siempre que sea posible para evitar reasignaciones accidentales, pero recuerda que no garantiza inmutabilidad real. - Usa
**readonly**
y**ReadonlyArray<T>**
para comunicar intenciones de inmutabilidad a nivel de tipo. - Usa
**Object.freeze()**
o**deepFreeze()**
cuando necesites garantizar inmutabilidad en tiempo de ejecución. - Considera bibliotecas como Immutable.js o Immer para proyectos grandes donde la inmutabilidad es crucial.
- Adopta patrones de transformación inmutable como práctica estándar en tu código funcional.
La inmutabilidad real va más allá de simplemente usar const
y requiere un enfoque consciente en cómo manipulamos los datos. Al entender esta distinción y aplicar las técnicas adecuadas, podemos aprovechar plenamente los beneficios de la programación funcional en TypeScript.
Ejercicios de esta lección Conceptos básicos e inmutabilidad
Evalúa tus conocimientos de esta lección Conceptos básicos e inmutabilidad con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
Funciones
Reto composición de funciones
Reto tipos especiales
Reto tipos genéricos
Módulos
Polimorfismo
Funciones TypeScript
Interfaces
Funciones puras
Reto namespaces
Funciones flecha
Polimorfismo
Operadores
Conversor de unidades
Funciones flecha
Control de flujo
Herencia
Clases
Proyecto validación de tipado
Clases y objetos
Encapsulación
Herencia
Proyecto sistema de votación
Reto genéricos con clases
Inmutabilidad
Interfaces
Funciones de alto orden
Reto map y filter
Control de flujo
Interfaces
Reto funciones orden superior
Herencia y clases abstractas
Reto tipos mapped
Herencia de clases
Reto funciones puras
Variables y constantes
Introducción a TypeScript
Reto testing unitario
Funciones de primera clase
Clases
OOP y CRUD en TypeScript
Interfaces y su implementación
Tipos genéricos
Namespaces
Proyecto calculadora gastos
Operadores y expresiones
Proyecto generador de contraseñas
Reto unión e intersección
Encapsulación
Tipos de unión e intersección
Tipos de unión e intersección
Reto hola mundo en TS
Variables y constantes
Funciones puras
Control de flujo
Introducción a TypeScript
Resolución de módulos
Control de flujo
Reto tipos de utilidad
Reto tipos literales y condicionales
Reto exportar e importar
Propiedades y métodos
Tipos de utilidad
Clases y objetos
Tipos de datos, variables y constantes
Proyecto Minigestor de tareas
Operadores
Funciones flecha y contexto
Funciones
Reto type aliases
Funciones de alto orden
Funciones y parámetros tipados
Tipos literales
Reto enums
Tipos de utilidad
Modificadores de acceso y encapsulación
Polimorfismo
Tipos genéricos
Reto módulos
Tipos literales
Inmutabilidad
Proyecto Generator de datos
Variables y constantes
Funciones de primera clase
Todas las lecciones de TypeScript
Accede a todas las lecciones de TypeScript y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Introducción A Typescript
Introducción Y Entorno
Instalación Y Configuración De Typescript
Introducción Y Entorno
Tipos De Datos, Variables Y Constantes
Sintaxis
Operadores Y Expresiones
Sintaxis
Control De Flujo
Sintaxis
Funciones Y Parámetros Tipados
Sintaxis
Funciones Flecha Y Contexto
Sintaxis
Enums
Sintaxis
Type Aliases Y Aserciones De Tipo
Sintaxis
Clases Y Objetos
Programación Orientada A Objetos
Interfaces Y Su Implementación
Programación Orientada A Objetos
Modificadores De Acceso Y Encapsulación
Programación Orientada A Objetos
Herencia Y Clases Abstractas
Programación Orientada A Objetos
Polimorfismo
Programación Orientada A Objetos
Decoradores Básicos
Programación Orientada A Objetos
Propiedades Y Métodos
Programación Orientada A Objetos
Inmutabilidad
Programación Funcional
Funciones Puras
Programación Funcional
Funciones De Primera Clase
Programación Funcional
Funciones De Alto Orden
Programación Funcional
Conceptos Básicos E Inmutabilidad
Programación Funcional
Funciones De Primera Clase Y Orden Superior
Programación Funcional
Composición De Funciones
Programación Funcional
Métodos Funcionales De Arrays (Map, Filter, Reduce)
Programación Funcional
Tipos Literales
Tipos Intermedios Y Avanzados
Tipos Genéricos
Tipos Intermedios Y Avanzados
Tipos De Unión E Intersección
Tipos Intermedios Y Avanzados
Tipos De Utilidad
Tipos Intermedios Y Avanzados
Unknown, Never Y Tipos Especiales
Tipos Intermedios Y Avanzados
Tipos Mapped
Tipos Intermedios Y Avanzados
Genéricos Con Clases E Interfaces
Tipos Intermedios Y Avanzados
Módulos
Namespaces Y Módulos
Namespaces
Namespaces Y Módulos
Resolución De Módulos
Namespaces Y Módulos
Exportación E Importación De Módulos
Namespaces Y Módulos
Introducción A Módulos
Namespaces Y Módulos
Testing Unitario En Typescript
Testing
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender los principios fundamentales de la programación funcional y sus ventajas frente a la programación imperativa
- Entender el concepto de inmutabilidad como pilar de la programación funcional y sus beneficios para el desarrollo
- Dominar técnicas para realizar operaciones inmutables con arrays y objetos en TypeScript
- Diferenciar entre el uso de 'const' (inmutabilidad de referencia) e inmutabilidad real en estructuras de datos
- Implementar patrones avanzados para trabajar con estructuras inmutables en aplicaciones TypeScript