TypeScript

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ícate

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

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

30 % DE DESCUENTO

Plan mensual

19.00 /mes

13.30 € /mes

Precio normal mensual: 19 €
63 % DE DESCUENTO

Plan anual

10.00 /mes

7.00 € /mes

Ahorras 144 € al año
Precio normal anual: 120 €
Aprende TypeScript online

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

TypeScript
Test

Reto composición de funciones

TypeScript
Código

Reto tipos especiales

TypeScript
Código

Reto tipos genéricos

TypeScript
Código

Módulos

TypeScript
Test

Polimorfismo

TypeScript
Código

Funciones TypeScript

TypeScript
Código

Interfaces

TypeScript
Puzzle

Funciones puras

TypeScript
Puzzle

Reto namespaces

TypeScript
Código

Funciones flecha

TypeScript
Puzzle

Polimorfismo

TypeScript
Test

Operadores

TypeScript
Test

Conversor de unidades

TypeScript
Proyecto

Funciones flecha

TypeScript
Test

Control de flujo

TypeScript
Código

Herencia

TypeScript
Puzzle

Clases

TypeScript
Puzzle

Proyecto validación de tipado

TypeScript
Proyecto

Clases y objetos

TypeScript
Código

Encapsulación

TypeScript
Test

Herencia

TypeScript
Test

Proyecto sistema de votación

TypeScript
Proyecto

Reto genéricos con clases

TypeScript
Código

Inmutabilidad

TypeScript
Puzzle

Interfaces

TypeScript
Test

Funciones de alto orden

TypeScript
Test

Reto map y filter

TypeScript
Código

Control de flujo

TypeScript
Test

Interfaces

TypeScript
Código

Reto funciones orden superior

TypeScript
Código

Herencia y clases abstractas

TypeScript
Código

Reto tipos mapped

TypeScript
Código

Herencia de clases

TypeScript
Código

Reto funciones puras

TypeScript
Código

Variables y constantes

TypeScript
Puzzle

Introducción a TypeScript

TypeScript
Test

Reto testing unitario

TypeScript
Código

Funciones de primera clase

TypeScript
Puzzle

Clases

TypeScript
Test

OOP y CRUD en TypeScript

TypeScript
Proyecto

Interfaces y su implementación

TypeScript
Código

Tipos genéricos

TypeScript
Test

Namespaces

TypeScript
Test

Proyecto calculadora gastos

TypeScript
Proyecto

Operadores y expresiones

TypeScript
Código

Proyecto generador de contraseñas

TypeScript
Proyecto

Reto unión e intersección

TypeScript
Código

Encapsulación

TypeScript
Puzzle

Tipos de unión e intersección

TypeScript
Test

Tipos de unión e intersección

TypeScript
Puzzle

Reto hola mundo en TS

TypeScript
Código

Variables y constantes

TypeScript
Código

Funciones puras

TypeScript
Test

Control de flujo

TypeScript
Código

Introducción a TypeScript

TypeScript
Código

Resolución de módulos

TypeScript
Test

Control de flujo

TypeScript
Puzzle

Reto tipos de utilidad

TypeScript
Código

Reto tipos literales y condicionales

TypeScript
Código

Reto exportar e importar

TypeScript
Código

Propiedades y métodos

TypeScript
Código

Tipos de utilidad

TypeScript
Test

Clases y objetos

TypeScript
Código

Tipos de datos, variables y constantes

TypeScript
Código

Proyecto Minigestor de tareas

TypeScript
Proyecto

Operadores

TypeScript
Puzzle

Funciones flecha y contexto

TypeScript
Código

Funciones

TypeScript
Puzzle

Reto type aliases

TypeScript
Código

Funciones de alto orden

TypeScript
Puzzle

Funciones y parámetros tipados

TypeScript
Código

Tipos literales

TypeScript
Puzzle

Reto enums

TypeScript
Código

Tipos de utilidad

TypeScript
Puzzle

Modificadores de acceso y encapsulación

TypeScript
Código

Polimorfismo

TypeScript
Puzzle

Tipos genéricos

TypeScript
Puzzle

Reto módulos

TypeScript
Código

Tipos literales

TypeScript
Test

Inmutabilidad

TypeScript
Test

Proyecto Generator de datos

TypeScript
Proyecto

Variables y constantes

TypeScript
Test

Funciones de primera clase

TypeScript
Test

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

TypeScript

Introducción Y Entorno

Instalación Y Configuración De Typescript

TypeScript

Introducción Y Entorno

Tipos De Datos, Variables Y Constantes

TypeScript

Sintaxis

Operadores Y Expresiones

TypeScript

Sintaxis

Control De Flujo

TypeScript

Sintaxis

Funciones Y Parámetros Tipados

TypeScript

Sintaxis

Funciones Flecha Y Contexto

TypeScript

Sintaxis

Enums

TypeScript

Sintaxis

Type Aliases Y Aserciones De Tipo

TypeScript

Sintaxis

Clases Y Objetos

TypeScript

Programación Orientada A Objetos

Interfaces Y Su Implementación

TypeScript

Programación Orientada A Objetos

Modificadores De Acceso Y Encapsulación

TypeScript

Programación Orientada A Objetos

Herencia Y Clases Abstractas

TypeScript

Programación Orientada A Objetos

Polimorfismo

TypeScript

Programación Orientada A Objetos

Decoradores Básicos

TypeScript

Programación Orientada A Objetos

Propiedades Y Métodos

TypeScript

Programación Orientada A Objetos

Inmutabilidad

TypeScript

Programación Funcional

Funciones Puras

TypeScript

Programación Funcional

Funciones De Primera Clase

TypeScript

Programación Funcional

Funciones De Alto Orden

TypeScript

Programación Funcional

Conceptos Básicos E Inmutabilidad

TypeScript

Programación Funcional

Funciones De Primera Clase Y Orden Superior

TypeScript

Programación Funcional

Composición De Funciones

TypeScript

Programación Funcional

Métodos Funcionales De Arrays (Map, Filter, Reduce)

TypeScript

Programación Funcional

Tipos Literales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Genéricos

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Unión E Intersección

TypeScript

Tipos Intermedios Y Avanzados

Tipos De Utilidad

TypeScript

Tipos Intermedios Y Avanzados

Unknown, Never Y Tipos Especiales

TypeScript

Tipos Intermedios Y Avanzados

Tipos Mapped

TypeScript

Tipos Intermedios Y Avanzados

Genéricos Con Clases E Interfaces

TypeScript

Tipos Intermedios Y Avanzados

Módulos

TypeScript

Namespaces Y Módulos

Namespaces

TypeScript

Namespaces Y Módulos

Resolución De Módulos

TypeScript

Namespaces Y Módulos

Exportación E Importación De Módulos

TypeScript

Namespaces Y Módulos

Introducción A Módulos

TypeScript

Namespaces Y Módulos

Testing Unitario En Typescript

TypeScript

Testing

Accede GRATIS a TypeScript y certifícate

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