Fundamentos de la encapsulación: Ocultamiento de datos y control de acceso en JavaScript
La encapsulación es uno de los pilares fundamentales de la programación orientada a objetos que aprendimos junto con la herencia y el polimorfismo. Este principio permite ocultar los detalles internos de implementación de un objeto y exponer solo aquellas partes que son necesarias para su uso.
¿Qué es la encapsulación?
La encapsulación se basa en dos conceptos principales:
- Ocultamiento de datos: Restringir el acceso directo a ciertos componentes del objeto
- Control de acceso: Proporcionar interfaces controladas para interactuar con esos datos
El objetivo principal es proteger la integridad de los datos internos de un objeto, evitando modificaciones accidentales o incorrectas desde el exterior.
Convenciones de nomenclatura
Históricamente, JavaScript ha utilizado convenciones de nomenclatura para indicar que ciertas propiedades deberían tratarse como privadas:
class BankAccount {
constructor(owner) {
this.owner = owner;
this._balance = 0; // El prefijo _ indica "privado por convención"
}
deposit(amount) {
if (amount > 0) {
this._balance += amount;
return true;
}
return false;
}
getBalance() {
return this._balance;
}
}
En este ejemplo, _balance
utiliza el prefijo _
para indicar que es una propiedad que no debería accederse directamente desde fuera de la clase. Sin embargo, esta es solo una convención y no impone ninguna restricción real:
const account = new BankAccount("Alice");
account.deposit(100);
console.log(account.getBalance()); // 100 (acceso correcto)
account._balance = 1000000; // Funciona, pero rompe la encapsulación
Esta técnica es muy común en código JavaScript y es importante reconocerla, aunque no proporciona una verdadera encapsulación.
Closures para encapsulación
Antes de la introducción de clases en ES6, los closures eran el mecanismo principal para implementar encapsulación real en JavaScript:
function createCounter() {
let count = 0; // Variable privada
return {
increment() {
count++;
},
decrement() {
count--;
},
getValue() {
return count;
}
};
}
const counter = createCounter();
counter.increment();
console.log(counter.getValue()); // 1
console.log(counter.count); // undefined - no hay acceso directo
En este patrón, la variable count
está encapsulada dentro del closure y solo es accesible a través de los métodos proporcionados. Este enfoque ofrece una verdadera encapsulación, ya que no hay forma de acceder directamente a la variable privada.
Getters y setters
Los getters y setters proporcionan una forma más elegante de controlar el acceso a las propiedades:
class Temperature {
constructor(celsius) {
this._celsius = celsius;
}
get celsius() {
return this._celsius;
}
set celsius(value) {
if (value < -273.15) {
throw new Error("Temperature below absolute zero is not possible");
}
this._celsius = value;
}
get fahrenheit() {
return this._celsius * 9/5 + 32;
}
set fahrenheit(value) {
this.celsius = (value - 32) * 5/9;
}
}
Los getters y setters permiten:
- Validar datos antes de asignarlos
- Calcular valores derivados bajo demanda
- Mantener la consistencia entre propiedades relacionadas
- Ocultar la implementación interna
const temp = new Temperature(25);
console.log(temp.celsius); // 25
console.log(temp.fahrenheit); // 77
temp.celsius = 30;
console.log(temp.fahrenheit); // 86
// Validación en acción
try {
temp.celsius = -300; // Error: Temperature below absolute zero
} catch (e) {
console.log(e.message);
}
Este enfoque combina de manera efectiva la sintaxis de clase que aprendimos anteriormente con mecanismos para controlar el acceso a datos.
Símbolos para propiedades semi-privadas
Los símbolos introducidos en ES6 ofrecen otra forma de implementar propiedades semi-privadas:
const passwordSymbol = Symbol('password');
class User {
constructor(username, password) {
this.username = username;
this[passwordSymbol] = password;
}
validatePassword(input) {
return this[passwordSymbol] === input;
}
}
const user = new User("admin", "1234");
console.log(user.validatePassword("1234")); // true
console.log(user[passwordSymbol]); // Accesible si se conoce el símbolo
console.log(Object.keys(user)); // ["username"] - el símbolo no aparece
Las propiedades basadas en símbolos:
- No aparecen en iteraciones estándar como
Object.keys()
- No son accesibles directamente sin conocer el símbolo
- Proporcionan un nivel de privacidad práctica aunque no absoluta
La encapsulación en JavaScript ha evolucionado desde simples convenciones hasta mecanismos más robustos. Aunque ninguna de estas técnicas tradicionales ofrece una encapsulación perfecta, proporcionan diferentes niveles de protección y control de acceso que son útiles en distintos contextos de desarrollo.
¿Te está gustando esta lección?
Inicia sesión para no perder tu progreso y accede a miles de tutoriales, ejercicios prácticos y nuestro asistente de IA.
Más de 25.000 desarrolladores ya confían en CertiDevs
Técnicas modernas para la implementación de encapsulación: Campos privados y patrones de módulo
JavaScript ha evolucionado significativamente en los últimos años, introduciendo características nativas que permiten implementar una encapsulación más robusta y elegante. Las técnicas modernas ofrecen soluciones que van más allá de las convenciones y patrones tradicionales, proporcionando mecanismos de encapsulación con mayor integridad y claridad sintáctica.
Campos privados con # (ECMAScript 2022)
La adición más importante para la encapsulación en JavaScript moderno es la implementación de campos privados utilizando el prefijo #
. Esta característica proporciona una verdadera privacidad a nivel de lenguaje:
class BankAccount {
#balance;
#transactionHistory;
constructor(initialBalance = 0) {
this.#balance = initialBalance;
this.#transactionHistory = [];
if (initialBalance > 0) {
this.#addTransaction("initial deposit", initialBalance);
}
}
deposit(amount) {
if (amount <= 0) {
throw new Error("Deposit amount must be positive");
}
this.#balance += amount;
this.#addTransaction("deposit", amount);
return this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new Error("Withdrawal amount must be positive");
}
if (amount > this.#balance) {
throw new Error("Insufficient funds");
}
this.#balance -= amount;
this.#addTransaction("withdrawal", amount);
return this.#balance;
}
#addTransaction(type, amount) {
this.#transactionHistory.push({
type,
amount,
date: new Date()
});
}
getBalance() {
return this.#balance;
}
getTransactionHistory() {
// Devolvemos una copia para evitar modificaciones externas
return [...this.#transactionHistory];
}
}
A diferencia de las convenciones con guion bajo, los campos privados con #
están estrictamente encapsulados:
const account = new BankAccount(1000);
console.log(account.getBalance()); // 1000
account.deposit(500);
account.withdraw(200);
console.log(account.getBalance()); // 1300
// Intentos de acceso directo generan errores
try {
console.log(account.#balance); // SyntaxError
} catch (e) {
console.log("No se puede acceder a campos privados desde fuera");
}
Los campos privados con #
ofrecen varias ventajas clave:
- Son verdaderamente privados y no solo por convención
- Generan un error de sintaxis si se intenta acceder desde fuera
- No pueden ser descubiertos mediante técnicas de introspección como
Object.keys()
- Funcionan con métodos, getters y setters privados
Aunque los campos privados con #
son una característica relativamente reciente, proporcionan el mecanismo de encapsulación más robusto en JavaScript actual. Su soporte en navegadores modernos es bueno, pero para entornos más antiguos, es recomendable utilizar las técnicas alternativas que hemos visto.
Patrón de módulo moderno con ESM
El sistema de módulos nativo de JavaScript (ESM) proporciona otra capa de encapsulación a nivel de archivo:
// userService.js
const API_KEY = "secret_api_key"; // Variable privada al módulo
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function createUser(userData) {
if (!validateEmail(userData.email)) {
throw new Error("Invalid email format");
}
// Lógica que usa API_KEY internamente
return fetch("https://api.example.com/users", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
}).then(res => res.json());
}
export function getUser(id) {
// Implementación que usa API_KEY
}
// No exportamos validateEmail ni API_KEY
En otro archivo, solo podemos acceder a lo que se exporta explícitamente:
// app.js
import { createUser, getUser } from './userService.js';
// Podemos usar las funciones exportadas
createUser({ name: "Alice", email: "alice@example.com" });
// Pero no tenemos acceso a las partes privadas
// API_KEY y validateEmail no son accesibles aquí
Este patrón permite:
- Encapsular variables y funciones a nivel de módulo
- Exponer solo una API pública bien definida
- Mantener la seguridad de datos sensibles como claves API
- Organizar el código en unidades cohesivas
Patrón de módulo con IIFE (Immediately Invoked Function Expression)
Este patrón, que utiliza closures, ha sido una técnica estándar para encapsulación en JavaScript durante muchos años:
const calculator = (function() {
// Variables privadas
let result = 0;
// Funciones privadas
function validate(n) {
if (typeof n !== 'number') {
throw new Error('Only numbers are allowed');
}
}
// API pública
return {
add(n) {
validate(n);
result += n;
return this;
},
subtract(n) {
validate(n);
result -= n;
return this;
},
multiply(n) {
validate(n);
result *= n;
return this;
},
divide(n) {
validate(n);
if (n === 0) {
throw new Error('Cannot divide by zero');
}
result /= n;
return this;
},
getResult() {
return result;
},
clear() {
result = 0;
return this;
}
};
})();
// Uso
calculator.add(5).multiply(2).subtract(3).divide(2);
console.log(calculator.getResult()); // 3.5
Este patrón crea un ámbito privado donde podemos definir variables y funciones que no son accesibles desde el exterior, exponiendo solo una API pública bien definida.
WeakMaps para datos privados (enfoque alternativo)
Antes de los campos privados nativos, los WeakMaps ofrecían una solución elegante para almacenar datos privados:
// Una alternativa para entornos que no soportan campos privados
const privateData = new WeakMap();
class Person {
constructor(name, age) {
privateData.set(this, {
name,
age
});
}
getName() {
return privateData.get(this).name;
}
getAge() {
return privateData.get(this).age;
}
setAge(age) {
if (age < 0 || age > 120) {
throw new Error('Invalid age');
}
privateData.get(this).age = age;
}
celebrateBirthday() {
const data = privateData.get(this);
data.age += 1;
return `Happy ${data.age}th birthday, ${data.name}!`;
}
}
const person = new Person("Alice", 30);
console.log(person.getName()); // "Alice"
console.log(person.getAge()); // 30
console.log(person.celebrateBirthday()); // "Happy 31th birthday, Alice!"
Esta técnica sigue siendo útil en ciertos contextos, especialmente cuando se necesita compatibilidad con navegadores antiguos o cuando se trabaja con objetos que no son instancias de clases.
Las técnicas modernas de encapsulación en JavaScript proporcionan mecanismos robustos para proteger la integridad de los datos y crear APIs limpias y bien definidas. Los campos privados con #
y el sistema de módulos ESM representan un avance significativo en la madurez del lenguaje para la programación orientada a objetos.
Módulos ES (ECMAScript Modules)
El sistema de módulos nativo de JavaScript proporciona otra capa de encapsulación a nivel de archivo:
// userService.js
// Variables privadas al módulo
const API_KEY = "secret_api_key";
const BASE_URL = "https://api.example.com";
// Función privada
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// Funciones exportadas (API pública)
export function createUser(userData) {
if (!validateEmail(userData.email)) {
throw new Error("Invalid email format");
}
// Uso interno de variables privadas
return fetch(`${BASE_URL}/users`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
}).then(res => res.json());
}
export function getUser(id) {
return fetch(`${BASE_URL}/users/${id}`, {
headers: {
"Authorization": `Bearer ${API_KEY}`
}
}).then(res => res.json());
}
En otro archivo, solo podemos acceder a lo que se exporta explícitamente:
// app.js
import { createUser, getUser } from './userService.js';
// Podemos usar las funciones exportadas
createUser({ name: "Alice", email: "alice@example.com" });
// Pero no tenemos acceso a las partes privadas
// API_KEY, BASE_URL y validateEmail no son accesibles aquí
Este patrón permite:
- Encapsular variables y funciones a nivel de módulo
- Exponer solo una API pública bien definida
- Mantener la seguridad de datos sensibles
- Organizar el código en unidades cohesivas
Aplicaciones prácticas de la encapsulación: Integrando encapsulación en nuestros diseños orientados a objetos
La encapsulación no es solo un concepto teórico, sino una herramienta práctica para crear código más mantenible, seguro y flexible. Veamos cómo integrar la encapsulación con los conceptos de POO que hemos aprendido.
Encapsulación e invariantes de clase
La encapsulación nos permite mantener invariantes de clase - condiciones que siempre deben ser verdaderas para un objeto. Por ejemplo, un círculo siempre debe tener un radio positivo:
class Circle {
#radius;
constructor(radius) {
this.setRadius(radius);
}
// Getter
getRadius() {
return this.#radius;
}
// Setter con validación
setRadius(value) {
if (typeof value !== 'number' || value <= 0) {
throw new Error('Radius must be a positive number');
}
this.#radius = value;
}
// Métodos que dependen del estado interno
getArea() {
return Math.PI * this.#radius * this.#radius;
}
getCircumference() {
return 2 * Math.PI * this.#radius;
}
}
const circle = new Circle(5);
console.log(circle.getArea()); // ~78.54
circle.setRadius(10);
console.log(circle.getCircumference()); // ~62.83
try {
circle.setRadius(-5); // Error: Radius must be a positive number
} catch (e) {
console.log(e.message);
}
La encapsulación del radio permite que la clase mantenga su invariante (radio positivo) y evita que el objeto llegue a un estado inválido.
Encapsulación en herencia
La encapsulación funciona de manera interesante con la herencia. Las clases derivadas no pueden acceder a los campos privados de la clase base:
class Shape {
#color;
constructor(color) {
this.#color = color;
}
getColor() {
return this.#color;
}
setColor(color) {
this.#color = color;
}
// Método protegido (por convención) que las subclases deberían implementar
_calculateArea() {
throw new Error('_calculateArea() must be implemented by subclasses');
}
// Método público que utiliza el método protegido
getArea() {
return this._calculateArea();
}
}
class Rectangle extends Shape {
#width;
#height;
constructor(color, width, height) {
super(color);
this.#width = width;
this.#height = height;
}
// Implementación del método protegido
_calculateArea() {
return this.#width * this.#height;
}
// Getters y setters específicos
getWidth() { return this.#width; }
setWidth(width) { this.#width = width; }
getHeight() { return this.#height; }
setHeight(height) { this.#height = height; }
}
const rect = new Rectangle('red', 5, 3);
console.log(rect.getColor()); // 'red'
console.log(rect.getArea()); // 15
En este ejemplo vemos:
- La clase base
Shape
tiene un campo privado#color
y un método protegido (por convención)_calculateArea()
- La clase derivada
Rectangle
implementa el método protegido y añade sus propios campos privados - La herencia funciona correctamente mientras cada clase encapsula sus propios detalles internos
Encapsulación para modelos de dominio
La encapsulación es especialmente útil para implementar modelos de dominio que encapsulan reglas de negocio:
class ShoppingCart {
#items = [];
#taxRate = 0.21; // 21% de IVA
addItem(product, quantity = 1) {
if (quantity <= 0) {
throw new Error('Quantity must be positive');
}
const existingItem = this.#items.find(item => item.product.id === product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.#items.push({ product, quantity });
}
}
removeItem(productId) {
const index = this.#items.findIndex(item => item.product.id === productId);
if (index !== -1) {
this.#items.splice(index, 1);
}
}
updateQuantity(productId, quantity) {
if (quantity <= 0) {
return this.removeItem(productId);
}
const item = this.#items.find(item => item.product.id === productId);
if (item) {
item.quantity = quantity;
}
}
getItems() {
// Devolvemos una copia para prevenir modificaciones directas
return [...this.#items];
}
getSubtotal() {
return this.#items.reduce(
(sum, item) => sum + (item.product.price * item.quantity),
0
);
}
getTaxAmount() {
return this.getSubtotal() * this.#taxRate;
}
getTotal() {
return this.getSubtotal() + this.getTaxAmount();
}
getItemCount() {
return this.#items.reduce((count, item) => count + item.quantity, 0);
}
}
// Uso
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Laptop', price: 999.99 });
cart.addItem({ id: 2, name: 'Mouse', price: 29.99 }, 2);
console.log(`Items in cart: ${cart.getItemCount()}`);
console.log(`Subtotal: ${cart.getSubtotal().toFixed(2)} €`);
console.log(`Taxes: ${cart.getTaxAmount().toFixed(2)} €`);
console.log(`Total: ${cart.getTotal().toFixed(2)} €`);
Esta implementación encapsula:
- La estructura interna de almacenamiento de items (
#items
) - La tasa de impuesto (
#taxRate
) - Las reglas de negocio (como el manejo de cantidades no válidas)
- Los cálculos para subtotal, impuestos y total
Encapsulación para objetos de valor inmutables
La encapsulación combinada con inmutabilidad nos permite crear objetos de valor robustos:
class Money {
#amount;
#currency;
constructor(amount, currency) {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new TypeError('Amount must be a number');
}
if (typeof currency !== 'string' || currency.length !== 3) {
throw new TypeError('Currency must be a 3-letter ISO code');
}
this.#amount = amount;
this.#currency = currency.toUpperCase();
// Congelar la instancia para prevenir la adición de propiedades
Object.freeze(this);
}
get amount() { return this.#amount; }
get currency() { return this.#currency; }
// Operaciones que devuelven nuevos objetos en lugar de modificar el actual
add(other) {
if (!(other instanceof Money) || other.currency !== this.#currency) {
throw new Error('Cannot add different currencies');
}
return new Money(this.#amount + other.amount, this.#currency);
}
subtract(other) {
if (!(other instanceof Money) || other.currency !== this.#currency) {
throw new Error('Cannot subtract different currencies');
}
return new Money(this.#amount - other.amount, this.#currency);
}
multiply(factor) {
return new Money(this.#amount * factor, this.#currency);
}
toString() {
return `${this.#amount.toFixed(2)} ${this.#currency}`;
}
}
// Uso
const price = new Money(19.99, 'EUR');
const taxAmount = price.multiply(0.21);
const total = price.add(taxAmount);
console.log(`Price: ${price}`); // "19.99 EUR"
console.log(`Tax: ${taxAmount}`); // "4.20 EUR"
console.log(`Total: ${total}`); // "24.19 EUR"
Esta implementación combina encapsulación con inmutabilidad, lo que hace que los objetos Money
sean:
- Seguros para usar en entornos concurrentes
- Fáciles de razonar sobre su estado
- Menos propensos a errores debido a efectos secundarios
Conectando encapsulación con otros principios de POO
La encapsulación trabaja en conjunto con otros principios de POO:
// Clase base abstracta que define una interfaz
class PaymentMethod {
// Métodos abstractos que las subclases deben implementar
processPayment(amount) {
throw new Error('Method not implemented');
}
refundPayment(transactionId) {
throw new Error('Method not implemented');
}
}
// Implementación concreta con detalles encapsulados
class CreditCardPayment extends PaymentMethod {
#apiKey;
#merchantId;
#transactions = new Map();
constructor(apiKey, merchantId) {
super();
this.#apiKey = apiKey;
this.#merchantId = merchantId;
}
// Implementación del método de la interfaz
processPayment(amount) {
// En un caso real, esto llamaría a una API de pago
console.log(`Processing credit card payment of ${amount.toFixed(2)} €`);
const transactionId = this.#generateTransactionId();
this.#transactions.set(transactionId, {
amount,
timestamp: new Date(),
status: 'completed'
});
return {
success: true,
transactionId
};
}
// Implementación del método de la interfaz
refundPayment(transactionId) {
if (!this.#transactions.has(transactionId)) {
throw new Error(`Transaction ${transactionId} not found`);
}
const transaction = this.#transactions.get(transactionId);
console.log(`Refunding ${transaction.amount.toFixed(2)} € to credit card`);
transaction.status = 'refunded';
return {
success: true,
refundedAmount: transaction.amount
};
}
// Método privado
#generateTransactionId() {
return 'txn_' + Math.random().toString(36).substr(2, 9);
}
}
// Otro implementador de la misma interfaz
class PayPalPayment extends PaymentMethod {
#clientId;
#clientSecret;
#transactions = [];
constructor(clientId, clientSecret) {
super();
this.#clientId = clientId;
this.#clientSecret = clientSecret;
}
processPayment(amount) {
console.log(`Processing PayPal payment of ${amount.toFixed(2)} €`);
// Implementación específica...
// ...
}
refundPayment(transactionId) {
console.log(`Refunding PayPal transaction ${transactionId}`);
// Implementación específica...
// ...
}
}
// Cliente que utiliza el polimorfismo sin conocer los detalles internos
function checkout(cart, paymentMethod) {
const amount = cart.getTotal();
return paymentMethod.processPayment(amount);
}
// Uso
const cart = new ShoppingCart();
cart.addItem({ id: 1, name: 'Laptop', price: 999.99 });
const ccPayment = new CreditCardPayment('sk_test_123', 'merch_456');
const paypalPayment = new PayPalPayment('client123', 'secret456');
// Mismo método, diferentes implementaciones encapsuladas
const ccResult = checkout(cart, ccPayment);
const ppResult = checkout(cart, paypalPayment);
Este ejemplo muestra cómo la encapsulación trabaja junto con:
- Herencia: Las clases derivadas heredan la interfaz pero encapsulan su propia implementación
- Polimorfismo: Diferentes implementaciones pueden usarse intercambiablemente
- Abstracción: Los clientes solo ven la interfaz, no los detalles internos
Aprendizajes de esta lección
- Comprender el concepto de encapsulación en la programación orientada a objetos.
- Conocer las ventajas de aplicar la encapsulación en la organización y protección de la información interna de los objetos.
- Aprender cómo implementar la encapsulación en JavaScript utilizando diferentes enfoques como IIFE, clausuras, objetos literales y clases con getters y setters.
- Entender la diferencia entre propiedades y métodos públicos y privados, y cómo limitar el acceso a la información interna de los objetos.
- Conocer los pros y contras de los enfoques de encapsulación en JavaScript y saber cuándo utilizar cada uno según las necesidades del proyecto.
- Saber cómo la encapsulación mejora la modularidad y la mantenibilidad del código, y cómo evita la manipulación involuntaria de datos internos.
Completa JavaScript y certifícate
Únete a nuestra plataforma y accede a miles de tutoriales, ejercicios prácticos, proyectos reales y nuestro asistente de IA personalizado para acelerar tu aprendizaje.
Asistente IA
Resuelve dudas al instante
Ejercicios
Practica con proyectos reales
Certificados
Valida tus conocimientos
Más de 25.000 desarrolladores ya se han certificado con CertiDevs