Explorando los campos y métodos privados de clases en JavaScript: un tutorial completo

En el vertiginoso mundo del desarrollo web, JavaScript continúa evolucionando a un ritmo impresionante, incorporando constantemente nuevas características que no solo simplifican nuestro trabajo, sino que también nos permiten escribir código más robusto, seguro y mantenible. Una de las adiciones más significativas en las versiones recientes de ECMAScript, específicamente en ES2022, ha sido la introducción de los campos y métodos privados de clase. Esta característica representa un cambio fundamental en cómo gestionamos la encapsulación dentro de nuestras clases, acercando JavaScript a paradigmas de programación orientada a objetos que son comunes en otros lenguajes. Si alguna vez te has preguntado cómo proteger el estado interno de tus objetos de accesos y modificaciones externas no deseadas sin recurrir a trucos o convenciones, este tutorial es para ti. Prepárate para sumergirte en el funcionamiento de esta potente herramienta, comprender su necesidad y aprender a implementarla en tus proyectos, llevando tus habilidades en JavaScript al siguiente nivel.

La necesidad de encapsulación en JavaScript

A traditional Argentine gaucho in authentic attire stands confidently in a sunlit park setting.

Antes de la llegada de los campos privados, la encapsulación en JavaScript era un tema de debate y, a menudo, requería soluciones ingeniosas o el uso de convenciones. La encapsulación es un principio fundamental de la programación orientada a objetos que busca restringir el acceso directo a algunos de los componentes de un objeto, exponiendo solo una interfaz controlada. Esto es crucial para mantener la integridad del estado de un objeto y para facilitar la refactorización futura sin afectar el código que lo utiliza.

Tradicionalmente, los desarrolladores de JavaScript han recurrido a varias estrategias para simular la privacidad. Una de las más comunes era el uso de clausuras (closures), creando funciones de fábrica que devolvían objetos con métodos privilegiados que podían acceder a variables definidas en el ámbito de la fábrica. Aunque efectivo, este patrón podía llevar a una sintaxis más verbosa y a veces menos intuitiva para la definición de clases.

Otra técnica extendida era la convención de prefijar los nombres de las propiedades con un guion bajo (_), como _privateField. Sin embargo, esta es meramente una indicación para otros desarrolladores de que esa propiedad no debe ser accedida directamente; no ofrece ninguna protección real a nivel de lenguaje. Cualquier parte del código podía, y a menudo lo hacía, ignorar esta convención y manipular el estado interno del objeto, lo que podía conducir a errores difíciles de depurar y a un código más frágil.

Los WeakMaps también se utilizaron como una forma de lograr una encapsulación "verdadera" antes de los campos privados. Al usar una instancia de WeakMap como almacenamiento para los datos privados, y una referencia a la instancia del objeto como clave, se podía asegurar que solo el código con acceso a la instancia de WeakMap (generalmente dentro de la definición de la clase) pudiera acceder a esos datos. Aunque técnicamente sólido, este enfoque añadía una capa extra de complejidad y no se sentía tan orgánico como la encapsulación nativa ofrecida por otros lenguajes.

La carencia de una característica de privacidad inherente ha sido, en mi opinión, una de las mayores limitaciones de las clases en JavaScript durante mucho tiempo, obligándonos a elegir entre pragmatismo (guion bajo) y complejidad (closures/WeakMaps) en situaciones donde la integridad del objeto era primordial. Por eso, la introducción de los campos y métodos privados es tan bienvenida y representa un avance significativo en la madurez del lenguaje.

Campos y métodos privados: una solución nativa

La propuesta para campos de clase públicos y privados fue un esfuerzo del comité TC39 para estandarizar una forma de declarar propiedades directamente dentro de una clase, fuera del constructor. La parte privada de esta propuesta, identificada por el prefijo #, proporciona una verdadera encapsulación a nivel de lenguaje. Esto significa que los campos y métodos declarados como privados solo pueden ser accedidos desde dentro de la clase que los define, y no desde fuera de ella, ni siquiera por clases derivadas.

Esta característica, que se finalizó y se incluyó en ECMAScript 2022, resuelve de manera elegante y directa el problema de la encapsulación, proporcionando una sintaxis clara e inequívoca para definir miembros privados. Ya no necesitamos depender de convenciones o de patrones complejos para proteger el estado interno de nuestros objetos. Para más detalles sobre la propuesta y su estado, se puede consultar el repositorio de la propuesta de campos de clase en TC39.

Definiendo campos privados

La sintaxis para definir un campo privado es sorprendentemente sencilla: solo necesitas añadir un prefijo # al nombre de la propiedad. Estos campos pueden ser inicializados directamente en la declaración de la clase o en el constructor, como cualquier otra propiedad.

Consideremos un ejemplo de una clase CuentaBancaria. Es crucial que el saldo no pueda ser modificado directamente desde fuera de la clase, sino a través de métodos controlados como depositar o retirar.


class CuentaBancaria {
    #saldo; // Campo privado
constructor(saldoInicial) {
    if (saldoInicial < 0) {
        throw new Error('El saldo inicial no puede ser negativo.');
    }
    this.#saldo = saldoInicial;
}

depositar(cantidad) {
    if (cantidad <= 0) {
        throw new Error('La cantidad a depositar debe ser positiva.');
    }
    this.#saldo += cantidad;
    console.log(`Depósito de ${cantidad} realizado. Saldo actual: ${this.#saldo}`);
}

retirar(cantidad) {
    if (cantidad <= 0) {
        throw new Error('La cantidad a retirar debe ser positiva.');
    }
    if (cantidad > this.#saldo) {
        throw new Error('Fondos insuficientes.');
    }
    this.#saldo -= cantidad;
    console.log(`Retiro de ${cantidad} realizado. Saldo actual: ${this.#saldo}`);
}

getSaldo() {
    return this.#saldo;
}

}

const miCuenta = new CuentaBancaria(100); console.log(Saldo inicial: ${miCuenta.getSaldo()}); // Saldo inicial: 100

miCuenta.depositar(50); // Depósito de 50 realizado. Saldo actual: 150 miCuenta.retirar(20); // Retiro de 20 realizado. Saldo actual: 130

// Intento de acceso directo al campo privado (generará un error) try { console.log(miCuenta.#saldo); } catch (error) { console.error(Error al intentar acceder a #saldo directamente: ${error.message}); }

// Intento de modificación directa (generará un error) try { miCuenta.#saldo = 1000000; } catch (error) { console.error(Error al intentar modificar #saldo directamente: ${error.message}); }

Como puedes ver, cualquier intento de acceder o modificar #saldo desde fuera de la clase CuentaBancaria resultará en un SyntaxError. Esto es fundamental: no es un error en tiempo de ejecución, sino un error de sintaxis, lo que significa que el código ni siquiera se ejecutará si intenta acceder a un campo privado de forma indebida. Esto ofrece una capa de protección mucho más fuerte que cualquier convención o truco. En mi opinión, esta es una de las adiciones más significativas para la escritura de código robusto y modular en JavaScript desde la llegada de las clases mismas.

Accediendo y manipulando campos privados

Los campos privados están diseñados para ser accesibles y manipulables únicamente desde el interior de la propia clase. Esto se logra mediante otros métodos o campos de la misma clase. Para acceder al valor de un campo privado desde fuera de la clase, se deben proporcionar métodos públicos específicos, comúnmente conocidos como "getters", que devuelvan ese valor. De manera similar, si se permite la modificación controlada de un campo privado, se deben implementar "setters" públicos que validen y apliquen los cambios.

Volviendo a nuestro ejemplo de CuentaBancaria, el método getSaldo() actúa como un getter público que permite obtener el valor de #saldo de forma segura:


class Producto {
    #precio;
    #nombre;
constructor(nombre, precio) {
    this.#nombre = nombre;
    this.#precio = precio;
}

getNombre() {
    return this.#nombre;
}

getPrecio() {
    return this.#precio;
}

setPrecio(nuevoPrecio) {
    if (nuevoPrecio < 0) {
        throw new Error('El precio no puede ser negativo.');
    }
    this.#precio = nuevoPrecio;
    console.log(`El precio de ${this.#nombre} se ha actualizado a ${this.#precio}.`);
}

mostrarDetalles() {
    // Acceso interno a campos privados
    console.log(`Producto: ${this.#nombre}, Precio: ${this.#precio} €`);
}

}

const miProducto = new Producto('Libro de JavaScript', 35); miProducto.mostrarDetalles(); // Producto: Libro de JavaScript, Precio: 35 €

console.log(miProducto.getNombre()); // Libro de JavaScript console.log(miProducto.getPrecio()); // 35

miProducto.setPrecio(40); // El precio de Libro de JavaScript se ha actualizado a 40. miProducto.mostrarDetalles(); // Producto: Libro de JavaScript, Precio: 40 €

// Intentar acceder directamente (SyntaxError) try { console.log(miProducto.#precio); } catch (error) { console.error(Error: ${error.message}); }

Este patrón de getters y setters es una práctica estándar en la programación orientada a objetos para controlar cómo se interactúa con el estado interno de un objeto. Los campos privados aseguran que esta interacción siempre se realice a través de la interfaz pública definida por la clase, garantizando la validación y la consistencia de los datos.

Métodos privados

De la misma manera que existen campos privados, también podemos definir métodos privados utilizando la misma sintaxis de prefijo #. Los métodos privados son útiles para encapsular lógica interna de la clase que no debe ser expuesta como parte de su interfaz pública, pero que es necesaria para el funcionamiento de otros métodos públicos.

Imaginemos una clase ProcesadorDeDatos que realiza varias operaciones internas antes de entregar un resultado final. Algunas de estas operaciones pueden ser auxiliares y no relevantes para el usuario de la clase.


class ProcesadorDeDatos {
    #datosInternos;
constructor(datos) {
    this.#datosInternos = datos;
}

#validarDatos(datos) { // Método privado
    if (!Array.isArray(datos) || datos.some(isNaN)) {
        throw new Error('Los datos deben ser un array de números.');
    }
    return true;
}

#normalizar(datos) { // Método privado
    const max = Math.max(...datos);
    return datos.map(n => n / max);
}

#calcularPromedio(datos) { // Método privado
    const suma = datos.reduce((acc, curr) => acc + curr, 0);
    return suma / datos.length;
}

procesar() { // Método público que utiliza métodos privados
    if (!this.#validarDatos(this.#datosInternos)) {
        return null; // En realidad, #validarDatos lanzaría un error
    }

    console.log("Procesando datos...");
    const datosNormalizados = this.#normalizar(this.#datosInternos);
    const promedio = this.#calcularPromedio(datosNormalizados);

    return {
        datosOriginales: this.#datosInternos,
        datosNormalizados: datosNormalizados,
        promedioNormalizado: promedio
    };
}

}

const procesador = new ProcesadorDeDatos([10, 20, 30, 40, 50]); const resultado = procesador.procesar(); console.log(resultado); /* { datosOriginales: [ 10, 20, 30, 40, 50 ], datosNormalizados: [ 0.2, 0.4, 0.6, 0.8, 1 ], promedioNormalizado: 0.6 } */

// Intento de llamar a un método privado desde fuera (SyntaxError) try { procesador.#validarDatos([1, 2, 3]); } catch (error) { console.error(Error al intentar llamar a un método privado: ${error.message}); }

// Otro ejemplo con datos inválidos try { new ProcesadorDeDatos([1, 'a', 3]).procesar(); } catch (error) { console.error(Error con datos inválidos: ${error.message}); // Los datos deben ser un array de números. }

Los métodos privados son increíblemente útiles para mantener la cohesión de la clase, evitando que su interfaz pública se sobrecargue con funciones auxiliares que solo son relevantes para su implementación interna. Es una excelente manera de aplicar el principio de responsabilidad única a un nivel más granular dentro de la clase.

Campos y métodos estáticos privados

Las características de privacidad no se limitan solo a las propiedades y métodos de instancia. También podemos definir campos y métodos estáticos como privados. Los miembros estáticos pertenecen a la clase misma, no a las instancias individuales de la clase. Cuando un miembro estático es privado, solo puede ser accedido por otros miembros estáticos (públicos o privados) de la misma clase. Esto es ideal para encapsular utilidades internas de la clase o configuraciones que no deben ser expuestas.


class ConfiguradorAplicacion {
    static #DEFAULT_PORT = 8080; // Campo estático privado
    static #DEFAULT_HOST = 'localhost'; // Campo estático privado
static #validarPuerto(puerto) { // Método estático privado
    return typeof puerto === 'number' && puerto > 0 && puerto <= 65535;
}

static #validarHost(host) { // Método estático privado
    return typeof host === 'string' && host.length > 0;
}

static getConfiguracionBase() { // Método estático público
    // Acceso interno a campos y métodos estáticos privados
    if (!this.#validarPuerto(this.#DEFAULT_PORT) || !this.#validarHost(this.#DEFAULT_HOST)) {
        throw new Error("Configuración estática por defecto inválida.");
    }
    return {
        host: this.#DEFAULT_HOST,
        puerto: this.#DEFAULT_PORT
    };
}

static getFullServerURL() { // Otro método estático público
    const config = this.getConfiguracionBase();
    return `http://${config.host}:${config.puerto}`;
}

}

console.log(ConfiguradorAplicacion.getConfiguracionBase()); // { host: 'localhost', puerto: 8080 }

console.log(ConfiguradorAplicacion.getFullServerURL()); // http://localhost:8080

// Intento de acceso a un campo estático privado (SyntaxError) try { console.log(ConfiguradorAplicacion.#DEFAULT_PORT); } catch (error) { console.error(Error al acceder a campo estático privado: ${error.message}); }

// Intento de llamar a un método estático privado (SyntaxError) try { ConfiguradorAplicacion.#validarPuerto(3000); } catch (error) { console.error(Error al llamar a método estático privado: ${error.message}); }

La capacidad de definir miembros estáticos privados es especialmente útil para implementar patrones como el Singleton, donde la lógica de creación de la instancia y la gestión de su estado pueden ser completamente internas a la clase, o para almacenar constantes y utilidades que solo la clase necesita para su propio funcionamiento interno, sin polucionar su interfaz pública.

Consideraciones y casos de uso

La adopción de campos y métodos privados ofrece una serie de ventajas claras, pero también es importante comprender sus implicaciones y cuándo es más apropiado utilizarlos.

Compatibilidad

Los campos y métodos privados son una característica relativamente reciente, estandarizada en ES2022. Esto significa que no todos los entornos de ejecución antiguos de JavaScript los soportarán de forma nativa. La mayoría de los navegadores modernos (Chrome, Firefox, Safari, Edge) y las versiones recientes de Node.js ya los implementan. Sin embargo, si tu proyecto necesita soportar entornos más antiguos, como Internet Explorer o versiones muy antiguas de Node.js, deberás utilizar un transpiler como Babel. Puedes consultar la compatibilidad actual en Can I Use....

Rendimiento

En términos de rendimiento, la implementación de campos privados es altamente optimizada por los motores de JavaScript. No hay una penalización significativa en comparación con las propiedades públicas o las soluciones de encapsulación tradicionales. De hecho, al ser una característica nativa del lenguaje, los motores pueden optimizar su acceso de maneras que son más difíciles de lograr con patrones basados en WeakMaps o closures.

Cuándo usar campos y métodos privados

  • Encapsulación estricta: Cuando la integridad del estado interno de un objeto es crítica y no debe ser alterada por código externo bajo ninguna circunstancia. Por ejemplo, saldos bancarios, claves de seguridad, estados internos complejos que podrían romperse si se manipulan directamente.
  • Mejora de la API pública: Al ocultar detalles de implementación, se simplifica la interfaz pública de una clase, haciéndola más fácil de usar y comprender. Esto reduce la superficie de ataque y la probabilidad de errores.
  • Refactorización segura: Permite refactorizar la lógica interna de una clase (cambiar nombres de campos, reorganizar métodos auxiliares) sin preocuparse por romper el código externo que depende de ella.
  • Colaboración en equipos: Proporciona una clara intención sobre qué partes de una clase son internas y cuáles son públicas, mejorando la legibilidad y la colaboración en equipos de desarrollo.

Aunque los campos privados son una herramienta poderosa, no es necesario hacer que cada propiedad sea privada. Si una propiedad es parte de la interfaz pública esperada de tu objeto y su modificación directa es inofensiva o incluso deseable, una propiedad pública está perfectamente bien. El objetivo es encontrar el equilibrio adecuado entre flexibilidad y protección.

Comparados con las soluciones anteriores, los campos privados son superiores en varios aspectos. Eliminan la ambigüedad de las convenciones de guion bajo, son más concisos que el patrón de WeakMap y más integrados al sistema de clases que las clausuras. En resumen, ofrecen la encapsulación que las clases de JavaScript siempre han necesitado.

Ejemplo práctico: un gestor de configuración seguro

Para solidificar nuestra comprensión, construyamos un ejemplo más complejo: un gestor de configuración para una aplicación. Este gestor necesitará almacenar claves de API, URLs de servicio y otros ajustes sensibles. Es vital que estos valores no sean accesibles o modificables directamente, sino a través de una A