Los objetos contienen propiedades clave-valor

Figura
Figura. Un objeto contiene propiedades como parejas clave-valor.

Las frases JavaScript está basado en objetos, Los objetos se relacionan a través de los prototipos y Los objetos contienen propiedades clave-valor podrían servir para identificar este lenguaje de programación. La última podría parecer innecesaria, pero describe la estructura de los objetos en JavaScript, dado que una propiedad es una entidad como una pareja clave-valor. Podríamos pensar, por ejemplo, en objetos que contengan una lista de valores en lugar de una lista de propiedades clave-valor.

La notación literal de un Array en JavaScript podría ser como ["abc", 789], aparentando ser sólo una lista de valores. Pero ese Array es sobre todo un objeto que está almacenado como Array {0: "a", 1: 789, length: 2}. Sus propiedades son parejas clave-valor. Las claves son numéricas y además el objeto tendrá una propiedad length que indicará el total de elementos que tiene el Array.

El nuevo built-in Set también es una lista de valores. Podemos crear un nuevo conjunto con new Set().add("abc").add(789). En la consola de los navegadores quizás lo veremos representado como Set {"abc", 789}.

Es parecido a un Array pero realmente su estructura interna es diferente al Array, pues será algo como Set {size: 2, [[Entries]]: Array{0: "abc", 1: "789", length: 2}}. Observe entonces que Set es también un objeto y no sólo una simple lista de valores. Sólo tiene una propiedad externa size que indica el total de elementos, los cuales se almacenan a su vez en la propiedad interna [[Entries]] por medio de, precisamente, un Array. Las propiedades internas se representan con corchetes dobles y no son directamente accesibles.

Por lo tanto cualquier objeto de JavaScript está constituido como una lista de propiedades, con cada propiedad constituida como parejas clave-valor. Sobre los métodos para crear y modificar esas propiedades nos ocuparemos en este tema.

Object.defineProperty(obj, prop, desc)

ES5

Con Object.defineProperty() creamos una nueva propiedad o modificamos una existente, devolviéndonos el objeto que deberá pasarse en el primer argumento. El segundo argumento es el nombre de la propiedad. El último argumento es un descriptor de propiedad. Hay dos grupos de descriptores, por un lado el descriptor de datos y por otro el descriptor de acceso. Ambos grupos comparten los descriptores configurable y enumerable. Si definimos una propiedad con un descriptor de datos podríamos usar value y writable como en el siguiente ejemplo:

let obj = Object.defineProperty({a: 1}, "b", {
    //Para ambos         // Valor predeterminado
    configurable: false, // false
    enumerable: false,   // false
    //Sólo para descriptor de dato
    value: 2,            // undefined
    writable: false      // false
});
console.log(obj); // Object {a: 1, b: 2}
    

En el apartado siguiente Object.defineProperties() explicaremos que significa cada uno de los descriptores configurable, enumerable y writable. En este apartado nos limitaremos a entender como definimos propiedades para un objeto.

Si definimos una propiedad con un descriptor de acceso haremos uso de los getter y setter. En el siguiente código vemos que pueden aparecer configurable y enumerable, pero en lugar de value y writable hacemos uso de los métodos get() para obtener el valor de la propiedad y set() para asignarle un nuevo valor. Observe como usamos una variable externa donde almacenamos el valor, pues realmente no lo estamos guardando en el objeto.

let x;
let obj = Object.defineProperty({a: 1}, "b", {

    //Para ambos           // Valor predeterminado
    configurable: false,   // false
    enumerable: true,     // false

    //Sólo para descriptor de acceso
    //Getter
    get: function (){      // undefined
        return x;
    },
    //Setter
    set: function(valor){  // undefined
        x = valor;
    }
});
console.log(obj); // Object {a: 1}
//Estableciendo con el setter la propiedad "b"
obj.b = 2;
//Obteniendo con el getter la propiedad "b"
console.log(obj.b); // 2
//La propiedad "b" formará parte de las claves del objeto
//si se establece la propiedad como enumerable
console.log(Object.keys(obj)); // ["a", "b"]
//En la consola del navegador no aparecerá inicialmente 
//la propiedad "b". El motivo se explica en el texto.
console.log(obj); // Object {a: 1}
    

Una asignación como obj.b = 2 se podría asimilar a la ejecución de un método obj.set(2) que modificara la variable externa. Luego con console.log(obj.b) sería como si ejecutáramos otro método get() haciendo console.log(obj.get()).

Pero no hay que olvidar que get() y set() no son métodos del objeto sino que son métodos del descriptor, por lo que no podrán ejecutarse directamente sobre el objeto:

let obj = Object.defineProperty({}, "a", {
    get : function (){return 1}
});
console.log(obj.get());
//TypeError: obj.get is not a function
    
Getters en la consola
Ver en la cónsola del navegador un objetoImagen no disponible
Una propiedad obtenible con getter aparecerá en la consola del navegador aún si valor, como indican los tres puntos suspensivos entre paréntesis.

Vamos a explicar ahora cuál es el mótivo por el que no aparece la propiedad b al hacer console.log(obj). Se debe a que la consola del navegador captura una instántanea del objeto, no rellenando aquellas propiedades que no estén disponibles en ese momento. Entre ellas están las definidas con getter. En la primera captura de la consola adjunta verá como el navegador representa el objeto.

El objeto aparece como Object {a: 1}, pero si desplegamos el objeto veremos la propiedad b sin valor. Hay tres puntos consecutivos a modo de enlace. Si nos sitúamos encima veremos en la segunda imagen que si se pulsa invocará una propiedad getter. Así ya en la tercera imagen se habrá obtenido el valor de esa propiedad.

Siguiendo con defineProperty() hay que remarcar que los dos grupos de descriptores de datos y de acceso son incompatibles. O usamos value y writable o usamos get y set.

let obj = Object.defineProperty({a: 1}, "b", {
    value: 2,
    get: function (){
        return 2;
    }
});
//TypeError: Invalid property descriptor. Cannot both specify 
//accessors and a value or writable attribute
    

Los tres argumentos de defineProperty() son requeridos. En el siguiente código pasamos un objeto vacío, una nueva propiedad con clave a y un objeto descriptor también vacío. Los descriptores tomarán los valores predeterminados que comentamos antes, tomándose el grupo descriptor de datos como predeterminado.

let obj = Object.defineProperty({}, "a", {});
console.log(obj); // Object {a: undefined}
    

Los símbolos también pueden ser nombres de propiedades. En este ejemplo creamos un nuevo símbolo y lo guardamos en una variable para posteriormente poder referirnos a él. Hacemos la propiedad writable para poder modificarla con una asignación, como veremos posteriormente en este tema.

let sym = Symbol("a");
let obj = Object.defineProperty({}, sym, 
    {value: 1, writable: true});
console.log(obj); // Object {Symbol(a): 1}
obj[sym] = 99;
console.log(obj); // Object {Symbol(a): 99}
    

Recuerde que podemos usar un símbolo global único en su alcance con Symbol.for(key) con lo que cada vez que lo referenciemos nos apuntará al mismo. Así evitaremos guardarlo en una variable:

let obj = Object.defineProperty({}, Symbol.for("a"),
    {value: 1, writable: true});
console.log(obj); // Object {Symbol(a): 1}
obj[Symbol.for("a")] = 99;
console.log(obj); // Object {Symbol(a): 99}
    

Accesores de datos getter y setter

Expliquemos un poco más sobre los accesores de datos o también llamados los getter y setter de un objeto. No habría ningún problema en usar las claves get y set como métodos del objeto, pero esto no es lo mismo que getter y setter:

let x;
let obj = {
    get: function(){return x},
    set: function(v){x=v}
};
obj.set(1);
console.log(obj.get()); // 1
    

Sin embargo los getter y setter pueden aplicarse directamente sobre un objeto en lugar de agregarlos con defineProperty(). En este caso no son métodos del objeto, sino verdaderos accesores de datos. En el siguiente ejemplo la propiedad b es tratada como accesor:

let x;
let obj = {
    get b(){return x},
    set b(v){x=v}
};
//Establecemos valor de la propiedad 
obj.b = 1;
//Obtenemos valor de la propiedad
console.log(obj.b); // 1
    

¿Y qué ventaja aportan estos accesores en lugar de usar propiedades como hacemos siempre? Supongamos que tenemos un módulo de JavaScript con una función que permite agregar un par de elementos al final de la página.

function agregarElementos(){
    document.body.innerHTML += `
    <div class="res">texto...</div>
    <div class="res">otro texto...</div>`;
}
    

Estos elementos tiene un nombre de clase "res" que nos permitirá dotarlos de estilo resaltado en otro momento de ejecución de dicho módulo, lo que haríamos con otra función:

function resaltar(){
    for (let elem of obj.res){
        elem.style.backgroundColor = "cyan"
    }
}
    

La variable obj será un objeto que aglutina propiedades para gestionar el módulo, por lo que habrá que declarar ese objeto con alcance global con respecto a dicho módulo. Contendrá una propiedad con una recopilación de todos los elementos con clase "res" obtenida con el método del DOM querySelectorAll().

let obj = {
    res: document.querySelectorAll(".res")
    // ... otras propiedades para gestionar el módulo
};
    

Es obvio que si la función agregar elementos se va a ejecutar posteriormente a esta declaración del objeto obj, la propiedad res no va a contener nada y nada será resaltado. Se soluciona usando un getter en la declaración del objeto:

let obj = {
    get res(){
        return document.querySelectorAll(".res")
    }
    // ... otras propiedades para gestionar el módulo
};
    

Cuando declaramos el objeto no se ejecutará el método get. Así cuando ejecutemos la función para resaltar, la propiedad obj.res será rellenada en ese momento. En el siguiente ejemplo interactivo podrá verlo en ejecución:

Ejemplo: Usando accesor getter

Agregar
Elementos agregados a la página:
Cada vez que agreguemos elementos previamente eliminaremos la propiedad con delete obj.res, a continuación agregaremos elementos con document.querySelectorAll(".res") sin o con un getter según la opción elegida y, finalmente, agregaremos los elementos al DOM. Así será como simular que estamos en el punto de carga de la página y el script está cargado pero los elementos aún no existen en el DOM.
Código agregado a la página obtenido del navegador:
 
Elementos en la lista de nodos obj.res.length =
Descriptores de la propiedad "res" obtenido del navegador:
 

Cada vez que ejecutemos resaltar se intentará llenar la lista de nodos en cualquiera de las dos primeras opciones. Con la primera opción la lista siempre estará vacía, por lo que no se resaltará ningún elemento. Con la segunda opción en cada ejecución se recupera la lista. Consultando obj.res.length observamos que contiene los dos elementos. Con la última opción que cachea la lista de nodos, ésta sólo será recuperada la primera vez, apareciendo primero el descriptor de acceso get y en las siguientes veces que pulsemos el botón de Resaltar aparecerá el descriptor de datos value.

Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Se podría argumentar que porque no usamos una propiedad res: null y rellenamos la lista de nodos obj.res antes de cada ejecución de la función para resaltar:

function resaltar(){
    obj.res = document.querySelectorAll(".res");
    for (let elem of obj.res){
        elem.style.backgroundColor = "cyan"
    }
}
    

Pero esto no siempre será posible. Por ejemplo, si es una lista de nodos muy larga que ocupa mucho tiempo obtenerla, quizás no nos interese ejecutar esa acción sino sólo la primera vez. O incluso esa lista de nodos posiblemente no llegue a usarse y por tanto no tenemos que estar invirtiendo tiempo en cargarla hasta que la necesitemos.

Si la lista de nodos no va a cambiar entre dos ejecuciones de la función resaltar() que la utiliza, podría ser conveniente cachearla. Así no tendríamos que cargarla nuevamente. Este cacheado de valor, que se conoce en inglés como memoization, se usa cuando el valor que devuelve una función no cambiará entre ejecuciones. En el siguiente ejemplo la primera vez que se ejecuta el getter se elimina la propiedad get res() (el propio getter). A continuación se asigna el valor a una propiedad con descriptor de datos, es decir, una propiedad con clave clásica. Así en los siguientes accesos ya no hay que volver a cargar la lista de nodos en el ejemplo interactivo anterior.

let obj = {
    get res(){
        delete this.res;
        return this.res = document.querySelectorAll(".res")
    }
    // ... otras propiedades para gestionar el módulo
};
    

Note que podemos eliminar la propiedad get res() porque la hicimos configurable, lo que explicaremos más adelante en un apartado sobre el descriptor configurable.

Object.defineProperties(obj, props)

ES5

El método genérico Object.defineProperties(obj, prop) agrega o modifica propiedades a un objeto que se pasará en el primer argumento. El segundo argumento es un objeto cuyas claves son los nombres de las propiedades a agregar o modificar y cuyos valores son objetos descriptores tal como se expuso en el apartado anterior Object.defineProperty().

let obj = Object.defineProperties({a: 1}, {
    b: {value: 2, enumerable: true},
    c: {get: function(){return 3}, enumerable: true}
});
console.log(Object.keys(obj)); // ["a", "b", "c"]
    

No hay diferencia de comportamiento con el método Object.defineProperty(), sólo que ahora podemos definir más de una propiedad.

Aprovecharemos este apartado para explicar los descriptores configurable, enumerable y writable. En primer lugar nos preguntaremos cuáles son los descriptores predeterminados. Los visualizaremos para un objeto donde creamos dos propiedades con defineProperties(), pero usando sólamente el descriptor de datos value:

//Objeto con definicion de valores
let obj = Object.defineProperties({}, {
    a: {value: 1},
    b: {value: 2}
});
    

En la otra opción del ejemplo usaremos un objeto literal con los mismas claves y valores de propiedades:

//Objeto literal
let obj = {a: 1, b: 2};
    

Por cada opción podremos recuperar los descriptores de las propiedades con el método getOwnPropertyDescriptor() que veremos con más detalle en un apartado más adelante. Basta decir ahora que ese método recupera los descriptores de una propiedad. El descriptor es un objeto del cual obtenemos las claves con el metodo de Array keys(). Usando el método también de Array reduce() obtenemos una representación de código del objeto descriptor de cada una de las dos propiedades.

document.getElementById("ver-descriptores").addEventListener("click", 
() => {
    let arr = ["a", "b"], cad = "";
    for (let clave of arr){
        cad += `"${clave}": {`;
        let descriptor = Object.getOwnPropertyDescriptor(obj, clave);
        cad += Object.keys(descriptor).reduce((p, v) => 
            `${p}\n${" ".repeat(3)}${v}: ${descriptor[v]}`, "")
        cad += '\n}\n';
    }
    document.getElementById("lista-descriptores").innerHTML = 
        wxRC.resaltar(cad, "js");
});
    

Si ejecuta el ejemplo obtendrá estos descriptores, donde se observa que para un objeto literal estarán activados por defecto los descriptores configurable, enumerable y writable, mientras que usando defineProperties() estarán desactivados.

Objeto literalCon defineProperties
"a": {
   value: 1
   writable: true
   enumerable: true
   configurable: true
}
"b": {
   value: 2
   writable: true
   enumerable: true
   configurable: true
}
"a": {
   value: 1
   writable: false
   enumerable: false
   configurable: false
}
"b": {
   value: 2
   writable: false
   enumerable: false
   configurable: false
}

Ejemplo: Descriptores predeterminados

Opción
//Objeto literal
let obj = {a: 1, b: 2};
        
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Por lo tanto si queremos definir una propiedad con descriptores y que se comporten como si hubiésemos usado una notación literal hemos de especificarlos explicítamente y valorarlos a true.

Object.getOwnPropertyDescriptor(obj, prop), Object.getOwnPropertyDescriptors(obj)

ES5/ES7

El método getOwnPropertyDescriptor(obj, prop) nos devuelve los descriptores de una propiedad de un objeto.

let obj = {a: 1};
let desc = Object.getOwnPropertyDescriptor(obj, "a");
console.log(desc); 
// Object {
// value: 1, 
// writable: true, 
// enumerable: true, 
// configurable: true}
    

En un tema anterior vimos como usar Object.create() para agregar un prototipo desde un objeto con una propiedad a la que también le pasábamos sus descriptores usando getOwnPropertyDescriptor():

let obj1 = {a: 1};
let obj2 = Object.create({b: 2}, {a:
    Object.getOwnPropertyDescriptor(obj1, "a")});
console.log(obj1 === obj2); // false
    

Observará que los objetos son distintos. El segundo tiene como prototipo una copia del primero, copiando no sólo el objeto sino también los descriptores de su única propiedad.

Si el primer objeto tuviese más de una propiedad tendríamos que usar getOwnPropertyDescriptors(obj). Pero el método estará disponible en ES7 (2017), por lo que por ahora podemos usar lo siguiente para implementarlo:

if (!Object.hasOwnProperty("getOwnPropertyDescriptors")){
    Object.getOwnPropertyDescriptors = function(obj) {
        let opd = {};
        let keys = Object.getOwnPropertyNames(obj).
            concat(Object.getOwnPropertySymbols(obj));
        for (let key of keys){
            opd[key] = Object.getOwnPropertyDescriptor(obj, key);
        }
        return opd;
    }
}
let obj1 = {a: 1, x: 5, y: 6};
obj2 = Object.create({b: 2}, Object.getOwnPropertyDescriptors(obj1));
console.log(obj1 === obj2); // false
console.log(obj2); // Object {a: 1, x: 5, y: 6}
console.log(Object.getPrototypeOf(obj2)); // Object {b: 2}
    

El descriptor enumerable

En el tema sobre iterar por un Array expusimos diversas formas de iterar por un Array usando métodos de Object. Pues en cualquier caso un Array también es un objeto. El método Object.keys() nos devuelve un Array de claves sólo si tienen el descriptor enumerable activado. Mientras que getOwnPropertyNames() nos devuelve ambas, enumerables y no enumerables. El bucle for-in, con o sin hasOwnProperty(), sólo devuelve las enumerables.

let obj = Object.defineProperties({}, {
    "a": {value: 1, enumerable: true},
    "b": {value: 2, enumerable: false}
});
//Con el método keys() obtenemos sólo las enumerables
console.log(Object.keys(obj)); // ["a"]
//Con getOwnPropertyNames() obtenemos enumerables y no enumerables
console.log(Object.getOwnPropertyNames(obj)); // ["a", "b"]
//Un bucle for-in con hasOwnProperty sólo accede a enumerables
let keys = [];
for (let prop in obj){
    keys.push(prop);
}
console.log(keys); // ["a"]
    

En el siguiente tema veremos que hay un método {}.propertyIsEnumerable() con el que podremos consultar si una propiedad tiene el descriptor enumerable activado. Consulte ese apartado para ver más detalles sobre el método.

let obj = Object.defineProperties({}, {
    "a": {value: 1, enumerable: true},
    "b": {value: 2, enumerable: false}
});
console.log(obj.propertyIsEnumerable("a")); // true
console.log(obj.propertyIsEnumerable("b")); // false
//Obtenemos lo mismo con getOwnPropertyDescriptor
console.log(Object.getOwnPropertyDescriptor(obj, "a").
    enumerable); // true
console.log(Object.getOwnPropertyDescriptor(obj, "b").
    enumerable); // false
    

El descriptor configurable

Ya vimos en un apartado anterior que si no especificamos writable, enumerable o configurable éstos son declarados como falsos. En la consola del navegador obtenemos con getOwnPropertyDescriptor() el objeto descriptor de un objeto al que le incorporamos una propiedad con defineProperty():

let obj = Object.defineProperty({}, "a", {value: 1});
console.log(Object.getOwnPropertyDescriptor(obj, "a"));
//Object {value: 1, 
//        writable: false, 
//        enumerable: false, 
//        configurable: false}
    

Si el descriptor no es configurable no podremos modificar ninguno de los descriptores de esa propiedad, ni siquiera el valor o el propio descriptor configurable. Nos lanzará un error de que no puede redefinir la propiedad:

//No podemos modificar los descriptores
try {
    obj = Object.defineProperty(obj, "a", {value: 0});
} catch(e){
    console.log(e.message); // Cannot redefine property: a
}
try {
    obj = Object.defineProperty(obj, "a", {writable: true});
} catch(e){
    console.log(e.message); // Cannot redefine property: a
}
try {
    obj = Object.defineProperty(obj, "a", {enumerable: true});
} catch(e){
    console.log(e.message); // Cannot redefine property: a
}
try {
    obj = Object.defineProperty(obj, "a", {configurable: true});
} catch(e){
    console.log(e.message); // Cannot redefine property: a
}
    

En cambio si hacemos la propiedad configurable

let obj = Object.defineProperty({}, "a", {
    value: 1, 
    configurable: true
});
console.log(Object.getOwnPropertyDescriptor(obj, "a"));
//Object {value: 1, 
//        writable: false, 
//        enumerable: false, 
//        configurable: true}

    

Entonces si que podemos modificar todos los descriptores, incluso el propio descriptor configurable.

obj = Object.defineProperty(obj, "a", {
    value: 0,
    writable: true,
    enumerable: true,
    configurable: false
});
console.log(Object.getOwnPropertyDescriptor(obj, "a"));
//Object {value: 0, 
//        writable: true, 
//        enumerable: true, 
//        configurable: false}
    

Otro efecto del descriptor configurable es que cuando está desactivado nos impide eliminar una propiedad usando el operador delete, no advirtiendo como error este hecho. En el siguiente ejemplo la primera propiedad es configurable y podemos eliminarla. La segunda no lo es y el borrado no tiene ningún efecto ni es advertido.

let obj = Object.defineProperties({}, {
    "a": {value: 1, configurable: true},
    "b": {value: 2, configurable: false}
});
console.log(obj); // Object {a: 1, b: 2}
//Eliminamos la propiedad "a"
delete obj.a;
console.log(obj); // Object {b: 2}
//No se eliminará la propiedad "b" y no se advertirá
delete obj.b;
console.log(obj); // Object {b: 2}
    

El descriptor writable

El descriptor writable indica si la propiedad puede ser modificada a través de una asignación. En el siguiente ejemplo tenemos que la propiedad b no es writable. Una reasignación del valor no será recogido y además no causará error. Si b fuera configurable podríamos modificar el descriptor value, como se observa en el ejemplo. Si no es configurable nos daría un error al intentar cambiar ese valor, como sucede con la propiedad c, que no es writable ni configurable. Es una propiedad inmutable pues no podrá ser eliminada (dado que no es configurable) ni modificada.

let obj = Object.defineProperties({}, {
    "a": {value: 1, writable: true, configurable: false},
    "b": {value: 2, writable: false, configurable: true},
    "c": {value: 3, writable: false, configurable: false}
});
//"a" es writable y puede reasignarse
obj.a = 99;
console.log(obj); // Object {a: 99, b: 2, c: 3}
//"b" no es writable y no puede reasignarse
obj.b = 88;
console.log(obj); // Object {a: 99, b: 2, c: 3}
//"b" es configurable y puede modificarse usando el descriptor
obj = Object.defineProperty(obj, "b", {value: 88});
console.log(obj); // Object {a: 99, b: 88, c: 3}
//"c" no es writable ni configurable y no puede 
//modificarse de ninguna forma. Es inmutable.
obj.c = 77;
console.log(obj); // Object {a: 99, b: 88, c: 3}
try {  
    obj = Object.defineProperty(obj, "c", {value: 77});
} catch(e){
    console.log(e.message); // Cannot redefine property: c
}
    

Por lo tanto un objeto inmutable es áquel en el que todas sus propiedades son inmutables y además no permite agregar nuevas propiedades. En el siguiente apartado veremos que el método Object.freeze() nos permitirá hacer inmutable un objeto.

Object.freeze(obj), Object.isFrozen(obj)

ES5

Congelar un objeto supone desactivar los descriptores writable y configurable, tal como vimos en el apartado anterior. El método Object.freeze() se encarga de hacer esto y bloquear el objeto para impedir agregar nuevas propiedades.

let obj = Object.defineProperty({}, "a", {
    value: 1,
    writable: true,
    enumerable: true,
    configurable: true
});
obj = Object.freeze(obj);
obj.a = 2;
console.log(obj); // Object {a: 1}
console.log(Object.getOwnPropertyDescriptor(obj, "a"));
// Object {
//    value: 1,
//    writable: false,
//    enumerable: true,
//    configurable: false
//}

    

El método Object.freeze() convierte en inmutable un objeto. El objeto queda congelado, lo que podemos consultar con Object.isFrozen(). En el siguiente ejemplo congelamos un objeto comprobando que no podemos modificar el valor de una propiedad con una asignación. Si no estamos en modo estricto no lanzará ningún error. Con defineProperty si que nos emitirá un error de que no puede redefinir la propiedad.

let obj = {a: 1}
obj = Object.freeze(obj);
//El objeto está congelado
console.log(Object.isFrozen(obj)); // true
//No podemos modificar el valor
obj.a = 99;
console.log(obj); // Object {a: 1}
//Ni con defineProperty
try {
    obj = Object.defineProperty(obj, "a", {value: 99})
} catch(e){
    console.log(e.message); // Cannot redefine property: a
}
    

Tampoco podemos agregar nuevas propiedades. Lanzará error en modo estricto y en modo normal con defineProperty:

let obj = {a: 1}
obj = Object.freeze(obj);
//No podemos agregar nuevas propiedades
obj.b = 2;
console.log(obj); // Object {a: 1}
//Ni con defineProperty
try {
    obj = Object.defineProperty(obj, "b", {value: 2})
} catch(e){
    console.log(e.message); // Cannot define property:b, 
                            // object is not extensible.
}
    

La eliminación de propiedades no tendrá ningún efecto:

let obj = {a: 1}
obj = Object.freeze(obj);
//No podemos eliminar propiedades
delete obj.a;
console.log(obj); // Object {a: 1}
    

Sin embargo si un valor es a su vez otro objeto y aunque no podamos reasignar ese valor si que podemos modificar el interior de ese otro objeto. Siempre que no esté congelado también. En el siguiente ejemplo congelamos un objeto. El valor de la primera propiedad es un Array, por tanto un objeto. Al no estar congelado, sus elementos pueden ser modificados:

let obj = {a: [1]}
obj = Object.freeze(obj);
obj.a[0] = 2;
console.log(obj); // Object {a: [2]}
    

Por lo tanto con freeze() conseguimos una congelación superficial. Para hacer una congelación profunda podríamos usar un recursivo que itere por el objeto buscando valores que sean a su vez objetos para congelarlos también. Usamos getOwnPropertyNames() que nos devuelve un Array de las claves de las propiedades enumerables y no enumerables. Lo concatenamos con getOwnPropertySymbols() pues las claves que sean símbolos no se incluyen en el anterior Array.

function congelar(obj){
    let keys = Object.getOwnPropertyNames(obj).
        concat(Object.getOwnPropertySymbols(obj));
    for (let key of keys){
        congelar(obj[key]);
    }
    return Object.freeze(obj);
}
let obj = {a: [1, {b: 2}], [Symbol.for("c")]: 3}
obj = congelar(obj);
//Modificación de valores no surtirán efecto,
obj.a[0] = 99;
obj.a[1].b = 88;
obj[Symbol.for("c")] = 77;
//pues todo el objeto está congelado
console.log(obj); // Object {a: [1, {b: 2}], Symbol(c): 3}
    

En el código anterior podría ser aconsejable preguntar en cada propiedad si es un objeto con typeof obj[key] == "object" para no intentar congelar algo que no sea un objeto. Pero el método Object.freeze(algo) funciona sea cual sea el tipo del argumento. Si no es un objeto, o incluso si es nulo, devolverá el mismo valor. Además si el objeto estuviera congelado previamente lo devolverá como está.

//Congelar tipos primitivos no tiene ningún efecto
console.log(Object.freeze(null)); // null
console.log(Object.freeze(undefined)); // undefined
console.log(Object.freeze(123)); // 123
console.log(Object.freeze("abc")); // "abc"
//Congelar varias veces no tiene ningún efecto
let obj = Object.freeze({a: 1});
console.log(Object.freeze(obj)); // Object {a: 1}
console.log(Object.isFrozen(obj)); // true
    

También podemos congelar un Array, pues es un objeto:

let arr = Object.freeze([1, 2, 3]);
//Modificar un elemento no tiene efecto
arr[1] = 99;
console.log(arr); // [1, 2, 3]
//Eliminar un elemento no tiene efecto
delete arr[1];
console.log(arr); // [1, 2, 3]
//Agregar un elemento no tiene efecto
arr[arr.length] = 4;
console.log(arr); // [1, 2, 3]
//Agregar con métodos de Array tampoco 
//tiene efecto pero acusa error
try {
    arr.push(4);
} catch(e){
    console.log(e.message); // Can't add property 3, 
                            // object is not extensible
}
    

Object.seal(obj), Object.isSealed(obj)

ES5

El método Object.seal() sella el objeto desactivando el descriptor configurable de todas sus propiedades. Esto tiene como principal efecto el impedimento para agregar nuevas propiedades. Aunque en modo normal agregando una propiedad no acusará error. Con el método Object.isSealed() comprobamos si un objeto está sellado. Recordando lo visto en apartados anteriores, configurable desactivado no permite modificar los descriptores, pero no impide una asignación directa si writable esté activado.

let obj = {a: 1, b: 2};
obj = Object.seal(obj);
console.log(Object.isSealed(obj)); // true
//El descriptor configurable será ahora falso
console.log(Object.getOwnPropertyDescriptor(obj, "a"));
//Object {value: 1, writable: true, enumerable: true,
//        configurable: false}
//No podemos agregar nuevas propiedades
obj.b = 2;
console.log(obj); // Object {a: 1}
//Pero si podemos cambiar el valor de las 
//propiedades existentes con writable activado
obj.a = 99;
console.log(obj); // Object {a: 99}
    

Si el argumento de seal() es un tipo primitivo (algo que no sea un objeto) el método lo devuelve tal cual. Además isSealed() considera siempre sellado un tipo primitivo aunque previamente no le apliquemos seal():

//Sellar un tipo primitivo no tiene ningún efecto
console.log(Object.seal(null)); // null
console.log(Object.seal(undefined)); // null
console.log(Object.seal(123)); // 123
//Los tipos primitivos están siempre sellados
console.log(Object.isSealed("abc")); // true
    

Al igual que con freeze(), es un sellado superficial. En el siguiente ejemplo tenemos objetos anidados, observando que sellar el objeto exterior no sella los interiores:

let obj = {x: {y: {z: [1]}}};
obj = Object.seal(obj);
console.log(Object.isSealed(obj)); // true
console.log(Object.isSealed(obj.x)); // false
console.log(Object.isSealed(obj.x.y)); // false
console.log(Object.isSealed(obj.x.y.z)); // false
    

Con un recursivo podemos sellar en profundidad:

function sellar(obj){
    let keys = Object.getOwnPropertyNames(obj).
        concat(Object.getOwnPropertySymbols(obj));
    for (let key of keys){
        sellar(obj[key]);
    }
    return Object.seal(obj);
}
let obj = {x: {y: {z: [1]}}};
obj = sellar(obj);
console.log(Object.isSealed(obj)); // true
console.log(Object.isSealed(obj.x)); // true
console.log(Object.isSealed(obj.x.y)); // true
console.log(Object.isSealed(obj.x.y.z)); // true
    

Como los Array también son objetos, podríamos sellar un Array. En este ejemplo sellamos el Array y no podemos agregar nuevos elementos. Con una asignación no da error en modo normal. Con métodos como push() si acusa error:

let arr = [1, 2, 3];
arr = Object.seal(arr);
//Agregar elementos no tiene ningún efecto
arr[arr.length] = 4;
console.log(arr); // [1, 2, 3]
//Con método push() da error
try {
    arr.push(4);
} catch(e){
    console.log(e.message); // Can't add property 3, 
                            // object is not extensible
}
    

Object.preventExtensions(obj), Object.isExtensible(obj)

ES5

El método Object.preventExtensions() previene extensiones impidiendo que se agreguen nuevas propiedades a un objeto, lo que lo hace no extensible. Sabremos que un objeto es extensible con el método Object.isExtensible(). Una vez que hagamos no extensible un objeto no hay forma de revertir la situación.

En el siguiente ejemplo hacemos no extensible un objeto, lo cual no actúa sobre los descriptores. No podremos agregar nuevas propiedades con una asignación aunque no acusará error en modo normal. En cambio usando defineProperty si acusará error.

let obj = {a: 1};
obj = Object.preventExtensions(obj);
console.log(Object.isExtensible(obj)); // false
//Prevenir extensiones no modifica los descriptores
console.log(Object.getOwnPropertyDescriptor(obj, "a"));
//Object {value: 1, writable: true, enumerable: true,
//        configurable: true}
//Pero no podemos agregar nuevas propiedades
obj.b = 2;
console.log(obj); // Object {a: 1}
//Ni siquiera usando defineProperty
try {
    obj = Object.defineProperty(obj, "b", {value: 2});
} catch(e){
    console.log(e.message); // Cannot define property:b, 
                            // object is not extensible.
}
    

Congelando, sellando o haciendo no extensible un objeto impiderá agregar nuevas propiedades, por lo que en principio hacen lo mismo. El congelado y el sellado tienen su descriptor configurable desactivado, mientras que el no extensible no afecta a sus descriptores. En el congelado o sellado no podemos actuar con defineProperty, pero en el no extensible si podríamos modificar los descriptores de las propiedades existentes.

En el siguiente ejemplo partimos de un objeto literal con lo que configurable estará activado. Haciendo no extensible el objeto no impedirá modificar la propiedad existente con defineProperty. Si estuviera congelado o sellado no podríamos hacerlo:

let obj = Object.preventExtensions({a: 1});
obj = Object.defineProperty(obj, "a", {
    value: 2,
    enumerable: false
});
console.log(obj); // Object {a: 2}
    

Los objetos inicialmente son extensibles de forma predeterminada. Si el argumento que pasamos a preventExtensions() no es un objeto, es decir, es un tipo primitivo, la ejecución no tiene ningún efecto devolviendo el valor tal cual.

//Un objeto literal es extensible de forma predeterminada
console.log(Object.isExtensible({})); // true
//Pero no lo son los tipos primitivos
console.log(Object.isExtensible(null)); // false
console.log(Object.isExtensible(undefined)); // false
console.log(Object.isExtensible(123)); // false
//Hacer no extensible un tipo primitivo no tiene efecto
let str = Object.preventExtensions("abc");
console.log(str); // "abc"
console.log(Object.isExtensible(undefined)); // false
    

Como en casos anteriores, el método preventExtensions() es de tratamiento superficial. Para hacer no extensible en profundidad necesitamos un recursivo. En el siguiente ejemplo todos los objetos anidados resultarán no extensibles:

function hacerNoExtensible(obj){
    let keys = Object.getOwnPropertyNames(obj).
        concat(Object.getOwnPropertySymbols(obj));
    for (let key of keys){
        hacerNoExtensible(obj[key]);
    }
    return Object.preventExtensions(obj);
}
let obj = {x: {y: {z: [1]}}};
obj = hacerNoExtensible(obj);
console.log(Object.isExtensible(obj)); // false
console.log(Object.isExtensible(obj.x)); // false
console.log(Object.isExtensible(obj.x.y)); // false
console.log(Object.isExtensible(obj.x.y.z)); // false
    

Un Array es un objeto que podemos hacer no extensible. En este ejemplo no podremos agregar nuevos elementos al Array. Con una asignación no acusará error en modo normal, pero usando métodos del prototipo de Array push() o unshift() que agregan elementos si acusará error en modo normal:

//Hacemos no extensible un Array
let arr = [1, 2, 3];
arr = Object.preventExtensions(arr);
//No podemos agregar elementos al Array
arr[arr.length] = 4;
console.log(arr); // [1, 2, 3]
//Ni con métodos del prototipo de Array
try {
    arr.push(4);
} catch(e){
    console.log(e.message); // Can't add 
        // property 3, object is not extensible
}
try {
    arr.unshift(4);
} catch(e){
    console.log(e.message); // Can't add 
        // property 3, object is not extensible
}
    

Relación entre los descriptores de un objeto y los métodos freeze(), seal() y preventExtensions()

En este apartado veremos la relación existente entre los descriptores configurable y writable con los métodos freeze(), seal() y preventExtensions. Si partimos de un objeto declarado con notación literal sabemos que los descriptores writable y configurable estarán activados por defecto. La ejecución de los métodos vistos sobre un objeto como ése conducirá al resumen de la siguiente tabla:

Métodoconfigurablewritable
let obj = {a: 1};truetrue
obj = Object.freeze(obj)falsefalse
obj = Object.seal(obj)falsetrue
obj = Object.preventExtensions(obj)truetrue

En el siguiente ejemplo interactivo podemos recrear esa situación. Partimos de un objeto con una única propiedad y le aplicamos uno de los metodos vistos. Tras eso probamos a modificar el objeto de varias formas y ver qué pasa.

Ejemplo: Relaciones entre descriptores y métodos de congelación

1º. Crear un objeto y aplicarle este método
Código a ejecutar:
let obj = {a: 1};
        
Descriptores del objeto:
2º. Editar el objeto
Código a ejecutar:
obj.a = 99;
        
Cómo es ahora el objeto:
Error capturado:
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.