Symbols en Javascript

Mejorar identificadores únicos con Symbol


En algunas situaciones puede que en nuestro código nos interese utilizar variables o propiedades para identificar ciertos elementos. Esos elementos serán localizados por identificadores únicos, que generalmente suele ser un código o un texto , e intentar que sean únicos.

¿Qué es un Symbol?

En Javascript, existe un tipo de dato llamado Symbol que no suele ser muy conocido. Se trata de una forma alternativa de crear identificadores únicos que ofrece algunas ventajas y garantías sobre usar nombres o números.

Antes de ver un ejemplo práctico, vamos a entender como funcionan los Symbol, ya que su funcionamiento es realmente simple:

const id = Symbol("id");            // Identificador de "id"
const unique = Symbol("unique");    // Identificador de "unique"

Symbol("unique") === Symbol("unique");  // false

Declarando un Symbol() y pasándole por parámetro un , Javascript creará un símbolo (identificador único) para ese texto y lo devuelve como resultado. Es una forma rápida y simple de tener algo realmente único. Además, es inmutable, por lo que no podemos modificarlo intencional ni accidentalmente.

Observa que en la última línea, a pesar de crear dos símbolos con el mismo , los dos objetos realmente no son el mismo, son diferentes porque son únicos.

Si lo que queremos es comprobar si su parámetro es el mismo, podemos acceder a su descripción:

const u1 = Symbol("unique");
const u2 = Symbol("unique");

u1 === u2;                            // false (son símbolos diferentes)
u1.description === u2.description;    // true  (se crearon con el mismo texto)

Veamos ahora, donde puede resultar interesante utilizar estos símbolos.

Crear identificadores únicos

Vamos a crear un ejemplo de un videojuego. De momento, no vamos a utilizar , sino que utilizaremos como identificadores «supuestamente únicos» para los tipos de enemigos del videojuego:

const enemies = [
  { id: "SKELETON", name: "Esqueleto" },
  { id: "SPECTRE", name: "Espectro" },
  { id: "GHOST", name: "Fantasma" }
];

const addEnemy = (id, name) => {
  enemies.push({ id, name });
}

// Añadimos nuevo esqueleto a la lista de enemigos
addEnemy("SKELETON", "Esqueleto resplandeciente");

const findEnemyById = (id) => {
  return enemies.find(enemy => enemy.id === id);
}

findEnemyById("SKELETON");
// Devuelve { id: "SKELETON", name: "Esqueleto" }

Observa que utilizando la responsabilidad de «recordar los nombres» de los ID de los enemigos corre de parte del programador. En este caso, el programador ha olvidado que existe otro esqueleto, y en lugar de establecer GLEAMING_SKELETON (caso ideal) ha establecido SKELETON (que es el mismo identificador que el primero que ya existía).

Por lo tanto, si más tarde buscamos el esqueleto resplandeciente por su identificador, nos devolverá el primero que encuentre, es decir, el SKELETON original (y no el último que hemos añadido).

Veamos ahora esta aproximación utilizando Símbolos, que recordemos que son únicos:

const SKELETON = Symbol("SKELETON");
const SPECTRE = Symbol("SPECTRE");
const GHOST = Symbol("GHOST");

const enemies = [
  { id: SKELETON, name: "Esqueleto" },
  { id: SPECTRE, name: "Espectro" },
  { id: GHOST, name: "Fantasma" }
];

// Enemies methods
const addEnemy = (id, name) => enemies.push({ id, name });
const findEnemyById = (id) => enemies.find(enemy => enemy.id === id);

// Añadimos nuevo esqueleto a la lista de enemigos
const GLEAMING_SKELETON = Symbol("SKELETON");
addEnemy(GLEAMING_SKELETON, "Esqueleto resplandeciente");

findEnemyById(GLEAMING_SKELETON);
// Devuelve: { id: Symbol(SKELETON), name: "Esqueleto resplandeciente" }

findEnemyById(SKELETON);
// Devuelve: { id: Symbol(SKELETON), name: "Esqueleto" }

Observa que al nuevo elemento le hemos asignado un símbolo que tiene SKELETON como descripción, igual que el esqueleto original. Sin embargo, como los símbolos son únicos, se consideran diferentes. Recuerda que simplemente los estamos metiendo en constantes (o en un objeto o una estructura más organizada) para mejorar su semántica y organización.

Crear símbolos globales

Los símbolos anteriores son locales. Es decir, sólo existen dentro de un ámbito concreto. Pero también podemos crear símbolos globales para que existan en un ámbito global de nuestra aplicación, incluso fuera de los módulos de Javascript.

const u1 = Symbol("unique");
const u2 = Symbol("unique");
u1 === u2; // false

const u1 = Symbol.for("unique");    // Símbolo global compartido
const u2 = Symbol.for("unique");    // Símbolo global compartido
u1 === u2; // true

Por otro lado, también podemos usar Symbol.keyFor() para pasar un símbolo global y obtener el pasado como descripción:

const local = Symbol("unique");
const global = Symbol.for("unique");

Symbol.keyFor(global);  // "unique"
Symbol.keyFor(local);   // undefined

Símbolos conocidos

En Javascript, internamente, se utilizan símbolos para el funcionamiento de ciertas características del lenguaje. Por ejemplo, puedes utilizar símbolos concretos para definir iteradores o para devolver valores primitivos, entre muchos otros.

Veamos algunos ejemplos, como por ejemplo, Symbol.iterator o Symbol.toPrimitive.

Símbolo Symbol.iterator

El símbolo Symbol.iterator se utiliza para definir como iterar un objeto. Observa el siguiente fragmento de código, donde hemos creado un objeto counter que tiene las propiedades start y end. La idea es que ese objeto pueda recorrerse entre esos dos elementos.

Observa también que tiene una una función que utiliza el símbolo Symbol.iterator para definir como debe iterarse el objeto:

const counter = {
  start: 1,
  end: 10,
  [Symbol.iterator]() {
    let current = this.start;
    const end = this.end + 1;
    return {
      next() {
        return {
          value: current++,
          done: current > end
        }
      }
    }
  }
}

Ahora, al tener definido ese métod next(), podemos iterar sobre el objeto counter utilizando un bucle for...of o incluso desestructurando:

// Muestra números del 1 al 10
for (const number of counter) {
  console.log(number);
}

// Devuelve [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const numbers = [...counter];

De la misma forma que tenemos Symbol.iterator, podemos utilizar Symbol.asyncIterator para definir un iterador asíncrono que utiliza operaciones que son propiamente asíncronas.

Si no entiendes muy bien esta parte, algo absolutamente normal, te recomiendo echar un vistazo a la sección de clausuras y de funciones generadoras.

Símbolo Symbol.toPrimitive

Otro símbolo conocido es Symbol.toPrimitive, mediante el cuál podemos definir funciones que se ejecutarán cuando se esté realizando una conversión implícita al tipo de dato en cuestión.

Por ejemplo, observa este fragmento de código donde hemos creado un objeto llamado theAnswer:

const theAnswer = {
  [Symbol.toPrimitive](hint) {
     if (hint === "string") {
       return "El sentido de la vida, el universo y todo lo demás.";
     }
     else if (hint === "number") {
       return 42;
     }
     else {
       return null;
     }
  }
}

Al definir el símbolo Symbol.toPrimitive como función, mediante su parámetro podemos definir la lógica a ejecutar dependiendo del tipo de dato al que se convierta.

Por ejemplo, ahora podemos hacer lo siguiente:

theAnswer           // Devuelve { [Symbol.toPrimitive]: f }

String(theAnswer)   // Devuelve "El sentido de la vida, el universo y todo lo demás."
Number(theAnswer)   // Devuelve 42

¿Quién soy yo?

Soy Manz, vivo en Tenerife (España) y soy streamer partner en Twitch y profesor. Me apasiona el universo de la programación web, el diseño y desarrollo web y la tecnología en general. Aunque soy full-stack, mi pasión es el front-end, la terminal y crear cosas divertidas y locas.

Puedes encontrar más sobre mi en Manz.dev