Wextensible

Symbol: Un nuevo tipo de datos primitivo de ES6

string, number, boolean, null, undefined y el nuevo tipo primitivo symbol
Figura. Symbol es un nuevo tipo de datos primitivo en ES6.

ECMAScript 6 (ES6) introduce los símbolos con el nuevo tipo de datos primitivo symbol. Su característica principal es que pueden actuar como identificadores, que por su naturaleza deben ser únicos e inmutables. Antes de seguir con los símbolos merece la pena refrescar algunas cosas relacionadas con los tipos de datos.

En JavaScript los tipos de datos primitivos son string, number, boolean, null y undefined. Son los únicos tipos de datos que no son especifícamente objetos, aunque JavaScript envuelve los tres primeros con sus correspondientes objetos String, Number y Boolean.

No hay que olvidar que JavaScript deduce los tipos de datos a partir de los valores. Si hacemos x = "" JavaScript entiende que la variable contiene un valor de tipo string, con x = 0 será de tipo number y con x = true será boolean. Por eso a las variables con tipos primitivos a veces les decimos literales, puesto que expresando su valor literal estamos declarando el tipo.

Por lo tanto es importante entender que son los valores los que poseen los tipos y no las variables. Si hiciéramos let x = "abc" estaríamos declarando un valor string. Pero si a continuación asignamos a la misma variable x = 123 ahora el valor sería number. Aunque para ahorrar texto pudiéramos decir la variable x es de tipo number deberíamos entenderlo como la variable x contiene un valor de tipo number.

Por otro lado también podemos hacer let x = new String("abc") (también puede omitirse new), generando un tipo de datos object, un objeto. Podemos aplicarle uno de sus métodos x.toUpperCase() para convertir el texto a mayúsculas. Con la envoltura que JavaScript aplica a los tipos de datos primitivos podemos también acceder a los métodos de su envoltura. Así podemos aplicar métodos directamente a los valores como "abc".toUpperCase().

Sintaxis de Symbol

Aunque symbol es un tipo de datos primitivo, también tiene una envoltura en el objeto Symbol. Pero symbol no puede generarse con una notación literal, como un string. Necesitamos usar una declaración como let x = Symbol() para tener en la variable x un valor de tipo symbol. En este caso no podemos usar alternativamente la palabra reservada new pues Symbol() no es un constructor, como si lo son String(), Number() o Boolean().

//Un símbolo siempre se declara usando Symbol()
let x = Symbol();
console.log(typeof x); // "symbol"
//Symbol() no es un constructor
let y = new Symbol(); // TypeError: Symbol is not a constructor
    
Todos los códigos expuestos en este tema han sido probados en Chrome 50, de donde hemos tomado las salidas console.log().

Cada vez que ejecutemos Symbol() creamos un nuevo símbolo único. Y aquí debemos entender símbolo como identificador. Por su naturaleza nunca deberían existir dos identificadores iguales. Pues eso es lo que se pretende, que no existan dos símbolos iguales:

let x = Symbol();
let y = Symbol();
console.log(x === y); // false
    

Por lo tanto los símbolos tiene valores únicos. Pero además no es posible modificar el contenido de un símbolo por lo que decimos que son inmutables. Y esto en el sentido más amplio, pues incluso una constante declarada con const no es del todo inmutable. Si a una constante le asignamos un valor de tipo primitivo, luego no podemos modificarlo:

//Constantes no se pueden reasignar
const n = 1;
n++; // TypeError: Assignment to constant variable.
    

Pero si le asignamos a una constante un objeto como un Array si podemos modificar el contenido del objeto:

//Valor Array en una constante
const m = [1];
//podemos modificar el contenido del array
m[0]++;
console.log(m); // [2]
//pero no podemos reasignar la constante de nuevo
m = 99 // TypeError: Assignment to constant variable.
    

Así que para las constantes lo que permanece inmutable es la atadura al valor asignado, no el propio valor. Sin embargo el valor de un símbolo si es verdaderamente inmutable, dado que ese valor no puede ser objeto de transformación con ningún operador o método. Ese valor no es ni siquiera representable. De hecho la función Symbol([descripcion]) tiene un argumento opcional para aportar una descripción con el que podemos representar el símbolo como String y aportar alguna información con el constructor String() o con el método toString():

//Un símbolo sin descripción
let sym = Symbol();
console.log(String(sym)); // "Symbol()"
console.log(sym.toString());// "Symbol()"
//Otro símbolo con descripción
let sym2 = Symbol("Valor");
console.log(String(sym2)); // "Symbol(Valor)"
console.log(sym2.toString());// "Symbol(Valor)"
    

Pero la descripción no guarda ninguna relación con el valor del símbolo. Su objetivo es sólo aportar información más bien para el programador, a efectos de realizar depuraciones y seguimiento de los mismos. Además no es posible la coerción de un símbolo, como en este ejemplo donde si coerciona un número a String para concatenarlos, pero no coerciona un símbolo a String.

//Coercionando un number a string para concatenar
let num = 123;
let str = "abc" + num;
console.log(str); // "abc123"
console.log(typeof str); // "string"
//Coercionar un símbolo da error
let sym = Symbol("sym");
str += sym; // TypeError: Cannot convert a Symbol value to a string
    

Asignando un símbolo a una constante como const X = Symbol() hace también inmutable la atadura al valor del símbolo. A partir de ese momento se cumplirá lo siguiente:

Y esas condiciones son las necesarias para hacer de X un buen identificador.

Símbolos en los identificadores de propiedades de objetos

Los nombres de propiedades de los objetos son también identificadores. Recuerde que estos identificadores son Strings. Aunque podemos obviar el entrecomillado si el String es un dígito numérico o un identificador válido de variable. Los nombres de identificadores válidos en ES5 y ES6 permiten también caracteres UNICODE. Yo siempre he optado por usar un patrón simple y seguro que sólo contiene código ASCII no extendido, con lo que me evito más líos. Si quieres ver más sobre esto puedes consultar la página Valid JavaScript variable names in ECMAScript 6 de Mathias Bynens.

Ese patrón simple y seguro es [a-zA-Z_$][\w$]*, con letras del conjunto ASCII no extendido. Debe empezar por un letra, un guión bajo o el signo dólar seguido de cero o más letras, dígitos númericos, guiones bajos o signos dólar. Recuerde que \w equivale a [a-zA-Z0-9_]. Si no responde a ese patrón debemos entrecomillarlo. En el siguiente código tenemos un objeto con tres propiedades, sin comillas, con comillas requeridas y un número entero:

let obj = {
    a   : 1, 
    "#" : 2, 
    9   : 3    
};
//El objeto completo
console.log(obj); // Object {9: 3, a: 1, #: 2}
//Acceso a sus elementos
console.log(obj.a, obj["#"], obj[9]); // 1 2 3
//Con corchetes su interior es una expresión 
//que devuelve un valor para usar como nombre
console.log(obj["a"]); // 1
console.log(obj["abc"[0]]); // 1
console.log(obj[String.fromCharCode(35)]); // 2
console.log(obj[8+1]); // 3
    

Podemos acceder a la propiedad con objeto.propiedad y objeto["propiedad"] indistintamente, aunque para los identificadores entrecomillados y números enteros debemos usar el acceso con corchetes. Observe que dentro de los corchetes se espera una expresión que devuelve un valor que será usado como nombre de la propiedad. Supongamos que posteriormente creamos un símbolo con el mismo nombre de una propiedad y lo agregamos al objeto anterior:

const a = Symbol("a");
obj[a] = 4;
console.log(obj); // Object {9: 3, a: 1, #: 2, Symbol(a): 4}
console.log(obj.a, obj["#"], obj[9], obj[a]); // 1 2 3 4
    

Observe ahora que al hacer obj[a] = 4 no estamos sobrescribiendo la anterior propiedad del mismo nombre, pues para ello habría que usar obj.a = 4 o bien obj["a"] = 4, con comillas. Así que los símbolos nos permiten agregar nuevas propiedades a un objeto sin que sus nombres entren en conflicto con los existentes.

Usando símbolos para no sobrescribir el objeto window

Figura
Figura. El objeto Window contiene un montón de propiedades.

El problema al final del apartado anterior no parece evidente con un objeto tan pequeño, pero si el objeto es tan grande como el global Window, sobrescribir algo podría ser poco afortunado. El siguiente ejemplo podría ser un poco forzado, pero supongamos que no sabemos que window.Map es el constructor del nuevo tipo de datos Map introducido en ES6. Quizás necesitamos tener una variable global denominada también Map aunque para otro cometido.

Recuerde que las variables se ubican en el objeto global con var cuando la declaración se hace a nivel de script, es decir, no dentro de una función. O en cualquier sitio cuando prescindimos de una declaración y no estamos en modo estricto. En cualquier caso el constructor del objeto intrínseco Map sería válidamente sobrescrito. Es decir, JavaScript no se opondría a esa sobrescritura. Si posteriormente lo necesitáramos no podríamos usarlo.

//También con var Map = null o incluso obviando var 
//si no estamos en modo estricto
window.Map = null;
console.log(window.Map); // null
//A partir de aquí ya no podemos usar el constructor Map()
let mapa = new window.Map([["a", 1]]); // TypeError: window.Map 
                                       // is not a constructor
    

Si en todo caso el nombre Map para un propiedad de window siguiera siendo necesario para nuestra aplicación, podríamos usar un símbolo atado a una variable declarada con let, pues esta declaración nunca pone las variables en global. Luego agregaríamos la propiedad con window[Map] que no sobrescribe la existe window.Map.

let Map = Symbol("Map"); // No sobrescribe window.Map
window[Map] = null;
console.log(window[Map]); // null
console.log(window.Map); // function Map() {[native code]}
console.log(window["Map"]); // function Map() {[native code]}
let mapa = new window.Map([["a", 1]]);
console.log(mapa); // Map {"a" => 1}
    

Observe que window.Map es equivalente a window["Map"], pero no es lo mismo que window[Map]. En este caso el identificador de la nueva propiedad es un símbolo, cuyo valor está atado a una variable que casualmente tiene el mismo nombre "Map".

Vimos que let Map = Symbol("Map") no sobrescribe el objeto window.Map en global. Pero no hay que olvidar que con las propiedades de window podemos prescindir del objeto para referirnos a ella, siempre que no exista otra propiedad con el mismo nombre en alcance local. Así vemos cosas como let str = new String("abc") para acortar new window.String("abc"). Pero en el código anterior tendremos que referirnos al constructor Map() usando window.Map porque en otro caso nos estaríamos refiriéndonos al símbolo de igual nombre.

Nombres de propiedades computadas y registro global de símbolos

Antes vimos que siempre podemos acceder al valor de una propiedad con corchetes, siendo su interior una expresión que devuelve un valor para usar como nombre de propiedad. Pues bien, ES6 introduce el concepto de nombres de propiedades computadas (computed property names) para usar lo mismo al definir propiedades literalmente. En el siguiente código componemos el mismo objeto que vimos en un apartado anterior {a: 1, "#": 2, 9: 3, [a]: 4} pero ahora usando sólo nombres de propiedades computadas.

const a = Symbol("a");
//El objeto {a: 1, "#": 2, 9: 3, [a]: 4} contiene las 
//mismas propiedades que el siguiente
let obj = {
    ["abc"[0]]: 1,
    [String.fromCharCode(35)]: 2,
    [8+1]: 3,
    [a]: 4
};
console.log(obj); // Object {9: 3, a: 1, #: 2, Symbol(a): 4}
    

Es muy cómodo definir propiedades con nombre computado para los símbolos, pero nos obliga a declarar el símbolo antes de declarar el objeto. Como los corchetes esperan una expresión, podríamos directamente crear el símbolo en el momento de definir la propiedad:

let obj = {
    [Symbol("a")]: 1
};
console.log(obj); // Object {Symbol(a): 1}
console.log(obj[a]); // ReferenceError: a is not defined
    

El problema es que ahora no podemos acceder a esa propiedad pues el símbolo no fue atado a ninguna variable. Podríamos hacer una asignación en la expresión:

let obj = {
    [a = Symbol("a")]: 1
};
console.log(obj); // Object {Symbol(a): 1}
console.log(obj[a]); // 1
//El símbolo fue colocado en global
console.log(window.a); // Symbol(a)
    

Ahora funciona pero la variable que contiene el símbolo fue colocada en el espacio global. Esto hay que evitarlo para no llenar window de cosas. Además en modo estricto no nos dejaría hacerlo.

(function() {
    "use strict";
    let obj = {
        [a = Symbol("a")]: 1 // ReferenceError: a is not defined
    };
})();
    

Una solución sería crear un objeto en el inicio del script para almacenar todas las variables atadas a símbolos:

//En el top del script definimos una constante que 
//almacenará todos los símbolos a usar
const $ = {};
//Luego en todo el script podemos usarlo
(function() {
    "use strict";
    let obj = {
        [$.a = Symbol("a")]: 1
    };
    console.log(obj); // Object {Symbol(a): 1}
    console.log(obj[$.a]); // 1
    console.log($); // Object {a: Symbol(a)}
    //Si en un momento posterior olvidamos que $.a ya se usó y lo 
    //volvemos a crear, se producirá una duplicación de entradas
    obj[$.a = Symbol("a")] = 2;
    console.log(obj); // Object {Symbol(a): 1, Symbol(a): 2}
    console.log(obj[$.a]); // 2
    console.log($); // Object {a: Symbol(a)}
})();
    

Hemos avanzado algo pero si tras el código anterior volviéramos a usar una identificador se crearía una duplicación de entradas, como se observa que usando de nuevo [$.a = Symbol("$.a")] se creará un nuevo símbolo. Para evitarlo deberíamos tener una función que controle esto, como la siguiente getSymbol() que nos crearía un nuevo símbolo si no existe en el registro o bien devolvería el existente. Además ya no sería necesario el modo estricto:

//En el top del script definimos una constante que almacenará
//todos los símbolos y gestor de ese registro de símbolos
const $ = {};
function getSymbol(key){
    if (!$.hasOwnProperty(key)){
        $[key] = Symbol(key);
    }
    return $[key];
}
//Luego en todo el script podemos usarlo (incluso sin modo estricto)
let obj = {
    [getSymbol("a")]: 1
};
console.log(obj); // Object {Symbol(a): 1}
console.log(obj[$.a]); // 1
console.log($); // Object {a: Symbol(a)}
//Usando otra vez el identificador $.a hará ahora lo correcto, usar
//el símbolo existente para sobrescribir la propiedad que ya existe
obj[getSymbol("a")] = 2;
console.log(obj); // Object {Symbol(a): 2}
console.log(obj[$.a]); // 2
console.log($); // Object {a: Symbol(a)}
    

El objeto Symbol tiene el método Symbol.for(key) que hace lo mismo que nuestra función getSymbol(key), con lo que podemos prescindir de esa función. Véase que Symbol("a") !== Symbol("a") pero Symbol.for("a") === Symbol.for("a").

let obj = {
    [Symbol.for("a")]: 1
};
console.log(obj); // Object {Symbol(a): 1}
console.log(obj[Symbol.for("a")]); // 1
//Usando otra vez el identificador "a" hará ahora lo correcto, usar
//el símbolo existente para sobrescribir la propiedad que ya existe
obj[Symbol.for("a")] = 2;
console.log(obj); // Object {Symbol(a): 2}
console.log(obj[Symbol.for("a")]); // 2
    

Observe como también podemos prescindir del objeto $ para almacenar las variables con los símbolos, pues Symbol.for("a") podemos usarlo para crear nuevas propiedades o leer las existentes, manteniendo siempre el mismo símbolo para la clave "a". Si queremos abreviar podemos utilizar un carácter acortador, como let _ = Symbol.for:

//Acortar referencia a Symbol.for() en el 
//inicio de la aplicación
let _ = Symbol.for;
//Ahora _(key) resuelve a Symbol.for(key)
let obj = {
    [_("a")]: 1
};
console.log(obj); // Object {Symbol(a): 1}
console.log(obj[_("a")]); // 1
//Usando otra vez el identificador "a" hará ahora lo correcto, usar
//el símbolo existente para sobrescribir la propiedad que ya existe
obj[_("a")] = 2;
console.log(obj); // Object {Symbol(a): 2}
console.log(obj[_("a")]); // 2
    

Otro método es Symbol.keyFor(sym) que recupera la clave para un determinado símbolo almacenado en el registro global. Para símbolos que no estén en ese registro nos devolverá undefined:

//Un símbolo local
let sym1 = Symbol("a");
console.log(Symbol.keyFor(sym1)); // undefined
//Un símbolo global
let sym2 = Symbol.for("a");
console.log(Symbol.keyFor(sym2)); // "a"
    

Si la aplicación es muy extensa o compartida por varios programadores podría producirse una colisión al usar Symbol.for("a"). Con Symbol.keyFor(sym) sólo podemos saber si un símbolo es local o global. Pero no creo que haya forma de consultar el registro global para ver si ya existe una determinada clave. Sería un método hipotético como Symbol.hasSymbolFor(key). Mientras esperamos por ese método hemos de usar algún truco para evitar la colisión de claves en el registro global. Por ejemplo, podemos usar un prefijo, como Symbol.for("miModulo.a"), Symbol.for("miModulo#a") o cualquier otra cosa similar, donde "miModulo" podría hacer referencia a los símbolos a usar en un determinado módulo.

Símbolos en una lista de constantes

Los símbolos son buenos para identificar una lista de constantes. Por ejemplo, una lista de descripciones de errores. En el siguiente ejemplo interactivo tenemos una supuesta aplicación para realizar cálculos numéricos. En el ejemplo solicitamos un número entero y calculamos su factorial. Debemos incorporar un control de errores sobre la entrada, para lo cual disponemos del siguiente código:

const MIN = 0, MAX = 100,
    ERROR_EMPTY = Symbol(),
    ERROR_NAN = Symbol(),
    ERROR_MIN = Symbol(),
    ERROR_MAX = Symbol(),
    ERROR_INTEGER = Symbol();

let errores = {
    [ERROR_EMPTY]: `El valor es vacío`,
    [ERROR_NAN]: `No es un número`,
    [ERROR_INTEGER]: `No es un número entero`,
    [ERROR_MIN]: `Es menor que el mínimo ${MIN}`,
    [ERROR_MAX]: `Es mayor que el máximo ${MAX}`
};

let esErroneo = (numero) => {
    if ((numero==="")||(numero===null)||(numero===undefined)){
        return ERROR_EMPTY;
    } else if (isNaN(numero)){
        return ERROR_NAN;
    } else if (!(/^[-+]?(?:0|[1-9]+\d*)$/.test(numero.toString()))){
        return ERROR_INTEGER;
    } else if (numero < MIN){
        return ERROR_MIN;
    } else if (numero > MAX){
        return ERROR_MAX;
    } else {
        return false;
    }
};
    

Podíamos haber utilizado valores numéricos para las constantes en lugar de símbolos. Por ejemplo const ERROR_NAN = 0. Pero en este caso podríamos referirnos a ese error con la variable ERROR_NAN o directamente con su valor cero. Por eso son preferibles los símbolos dado que únicamente podemos utilizar la variable que contiene el valor de un símbolo para identificar un error.

Ejemplo: Controlando errores con constantes y símbolos locales

con símbolos locales
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

El resto del código cuando pulsamos el botón no tiene mayor interés y lo puede ver en el enlace anterior. Ahí construimos el resultado o bien la frase de error, según sea el resultado de error = esErroneo(numero). Si hay error nos devuelve el símbolo de error. En otro caso nos devuelve false y procedemos a realizar el cálculo del factorial. La frase de error que componemos es la siguiente:

`Error con el valor ${numero}: 
${(errores[error]||"Error no identificado")}`
    

Podemos usar el registro global de símbolos para llevar a cabo otra versión de ese ejemplo, no siendo necesarias las declaraciones de constantes. Los identificadores de error se comportarán como constantes únicas e inmutables si están en el registro global de símbolos:

const MIN = 0, MAX = 100;

let errores = {
    [Symbol.for("ERROR_EMPTY")]: `El valor es vacío`,
    [Symbol.for("ERROR_NAN")]: `No es un número`,
    //...etcétera...
};

let esErroneo = (numero) => {
    if ((numero==="")||(numero===null)||(numero===undefined)){
        return Symbol.for("ERROR_EMPTY");
    } else if (isNaN(numero)){
        return Symbol.for("ERROR_NAN");
    //...etcétera...
};    
    

Ejemplo: Controlando errores con constantes y símbolos globales

con símbolos globales
Este ejemplo usa ES6 en modo estricto. Puedes consultar el código JS original de este ejemplo.

Ahora podemos usar Symbol.keyFor(error) para obtener el nombre del error. Si el error no está en el objeto de errores lo devolvemos como desconocido:

`Error ${(errores[error]) ? Symbol.keyFor(error) : "desconocido"}
con el valor ${numero}: ${(errores[error]||"Error no identificado")}`
    

En Symbol hay símbolos bien conocidos

Figura
Figura. Symbol tiene un par de métodos for() y keyFor(). Casi todo lo demás son símbolos.

Veamos que hay en el objeto Symbol, constructor de símbolos. Es un objeto Function y como cualquier otra función contiene las típicas propiedades arguments, caller, length, name, prototype y __proto__. Esto lo puede observar en la Figura.

Symbol tiene dos métodos genéricos propios que ya vimos en apartados anteriores: Symbol.for() y Symbol.keyFor(). El resto son a su vez símbolos que se denominan símbolos bien conocidos, término en principio un poco extraño, resultado de la traducción de well-known symbols.

Podemos recuperar la lista de propiedades de la Figura usando el método de Object para recuperar los nombres de propiedades getOwnPropertyNames(). En el siguiente ejemplo interactivo puede verlo en ejecución, donde podemos filtrar sólo los símbolos. El código del ejemplo lo puede ver en el enlace señalado.

Ejemplo: Lo que Symbol contiene

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

Los símbolos bien conocidos nos permiten cambiar el comportamiento de ciertas características internas a JavaScript. Se trata de que el programador pueda acceder a ellas para llevar a cabo modificaciones en la forma predeterminada de actuar. Hay una tabla en la especificación EcmaScript 6 Well-Known Symbols sobre los símbolos bien conocidos, donde se da una breve descripción.

La siguiente lista es la que se obtiene en Chrome 50, con los mismos símbolos que los expuestos en la especificación anterior. Estos enlaces llevan al tema siguiente donde los exponemos con detalle:

  1. Symbol.toStringTag
  2. Symbol.match, search, split y replace
  3. Symbol.hasInstance
  4. Symbol.toPrimitive
  5. Symbol.isConcatSpreadable
  6. Symbol.unscopables
  7. Symbol.species
  8. Symbol.iterator

En la lista del ejemplo interactivo anterior podemos ver que Chrome 50 ya incluye todos los símbolos bien conocidos de ES6. En Firefox 46 sólo obtenemos los símbolos iterator, match, species y toPrimitive. En el siguiente tema veremos que Chrome 50 reconoce Symbol.hasInstance y Symbol.species, pero no los soporta adecuadamente.

El prototipo de Symbol

Figura
Figura. Lo que hay en el prototipo de Symbol.

En el prototipo de Symbol, en base al cual se construyen nuevas instancias de símbolos con el constructor Symbol(), no hay muchas cosas (ver Figura). Los métodos del prototipo son escasos, sólo toString() y valueOf(). Éstos son los métodos heredados de Object que se sobrescriben en las instancias de todos los objetos creados en JavaScript. Veamos que sale de esos métodos:

let sym = Symbol("Mí símbolo");
let x = sym.toString();
console.log(x, typeof x); // "Symbol(Mí símbolo)" "string"
let y = sym.valueOf();
console.log(y, typeof y); // Symbol("Mí símbolo") "symbol"
console.log(sym === sym.valueOf()); // true
    

El método valueOf() nos devuelve el propio símbolo, como se deduce al realizar la comparación de la instancia con el valor devuelto por el método.

Vemos también los símbolos bien conocidos del prototipo Symbol.toStringTag y Symbol.toPrimitive. Expliquemos un poco que pasa con el primero de ellos. Cuando hacemos toString() sobre un objeto básico Object nos sale "[object Object]". Entonces asignando "Symbol" a Symbol[Symbol.toStringTag] se consigue obtener "[object Symbol]" llamando al toString() del prototipo de Object:

//Para un objeto básico el toString() devuelve "[object Object]"
let obj = {};
console.log(obj.toString()); // "[object Object]"  
//Para el resto de built-in que heredan de Object, se sobrescribe
//el método para que devuelvan "[object TIPO]" 
let sym = Symbol();
console.log(Object.prototype.toString.call(sym)); //"[object Symbol]"
    

En el tema siguiente sobre los Símbolos bien conocidos veremos esto con más detalle.