Symbol — это гарантированно уникальное значение
Symbol — один из примитивных типов в JavaScript, наряду со string, number, boolean, null, undefined и bigint. Его ключевая особенность проста: каждый созданный символ уникален и никогда не совпадёт ни с каким другим.
Два символа, оба созданы без аргументов — и всё равно не равны друг другу. Это не баг и не случайность, а весь смысл этой штуки. Символ нельзя подделать, нельзя случайно пересоздать и нельзя столкнуться с чужим.
При желании к символу можно приложить описание — оно помогает при отладке, но на идентичность никак не влияет:
Одно и то же описание — а символы всё равно разные. Description нужен исключительно для людей, которые потом будут смотреть логи.
Зачем нужны символы: ключи без коллизий
Главная причина, по которой Symbol вообще появился в JavaScript, — возможность добавлять свойства к объекту, не боясь, что ваш ключ столкнётся с чужим. Строковые ключи живут в одном плоском пространстве имён: если две библиотеки решат хранить метаданные в obj.meta, они молча затрут друг друга. С символьными ключами такого не бывает — ни у кого больше просто нет вашего символа.
Квадратные скобки в [ID]: 42 — это синтаксис вычисляемых свойств, означающий «возьми значение ID как ключ». Прочитать или перезаписать это свойство сможет только тот код, у которого на руках тот же самый символ ID. Любой другой модуль, который оперирует строкой "id" или своим собственным Symbol("id"), будет обращаться совсем к другой ячейке.
Ключи-символы (почти) невидимы
Свойства с ключами-символами не попадают ни в for...in, ни в Object.keys, ни в JSON.stringify. Это не секретность в полном смысле — при желании их всё равно можно достать, — но при обычном обходе они не мешаются под ногами.
Object.keys и JSON.stringify просто игнорируют ключи-символы. Если нужно целенаправленно добраться до свойств-символов, на помощь приходят Object.getOwnPropertySymbols и Reflect.ownKeys — они их покажут. Это как раз то поведение, которое хочется видеть у метаданных: при желании их можно найти, но код, который обходит обычные строковые ключи, их просто не заметит.
Общие символы через Symbol.for
Symbol() создаёт локальный, одноразовый символ. Но иногда нужно ровно противоположное — чтобы символ был одним и тем же во всей программе, в разных модулях, и чтобы разные куски кода могли договориться об общем ключе. Для этого и существует Symbol.for в javascript.
Symbol.for(key) обращается к глобальному реестру: если символ с таким ключом там уже есть, вы получите его же; если нет — он будет создан и сохранён. Symbol.keyFor работает в обратную сторону — возвращает ключ, с которым был зарегистрирован символ (или undefined, если символ не из реестра).
В большинстве случаев достаточно обычного Symbol(). К Symbol.for стоит обращаться только тогда, когда символ действительно нужно разделять между модулями.
Well-known symbols: зацепки внутри самого языка
Вот где символы показывают себя во всей красе. В JavaScript есть набор заранее определённых символов — так называемые well-known symbols, — которые движок сам ищет на ваших объектах. Достаточно реализовать метод по такому ключу, и объект автоматически подключается к встроенным возможностям языка.
Самый полезный из них — Symbol.iterator. Любой объект с методом по этому ключу становится итерируемым: с ним работают for...of, спред-оператор, деструктуризация и Array.from.
range — это не массив. Это обычный объект с единственным методом, у которого вместо строкового ключа стоит специальный символ. Но раз этот метод возвращает итератор, то и for...of, и спред-оператор спокойно с ним работают — точно так же, как с массивами, строками и Map. В этом и весь смысл контракта: реализовал Symbol.iterator — и язык считает тебя итерируемым объектом.
Другие well-known symbols, которые стоит знать
Их в JavaScript ещё несколько, и каждый — это хук в какую-то отдельную часть языка:
Symbol.iterator— делает объект итерируемым.Symbol.asyncIterator— то же самое, но дляfor await...of.Symbol.toPrimitive— управляет тем, как объект приводится к примитиву (числу, строке или «по умолчанию») при неявных преобразованиях.Symbol.hasInstance— настраивает поведениеinstanceofдля вашего класса.Symbol.toStringTag— задаёт тег, который показываетObject.prototype.toString.call(obj).
Заучивать весь список не обязательно. Достаточно помнить, что такие штуки есть: когда вам понадобится, чтобы объект вёл себя как встроенный тип, почти наверняка для этого найдётся подходящий well-known symbol.
Symbol — это не строка
Типичная ловушка: символы не приводятся к строке автоматически. Попытка склеить символ со строкой выбросит ошибку — равно как и передача его в API, ожидающий строку:
Шаблонные литералы выбросят тот же самый TypeError. Если вам действительно нужна строка — используйте String(symbol) или symbol.toString(). Эти острые углы сделаны специально: язык оберегает вас от случайного обращения со значением, чья суть — уникальность, как с обычной строкой.
Когда использовать Symbol в JavaScript, а когда — нет
Symbol пригодится, когда:
- Вы навешиваете метаданные на чужие объекты и вам нужен ключ, который гарантированно ни с чем не столкнётся.
- Вы проектируете протокол — мол, «объекты, которые хотят работать с моей библиотекой, должны реализовать метод под таким-то символом».
- Вам нужно свойство, которое не попадёт ни в
JSON.stringify, ни вfor...in. - Вы реализуете
Symbol.iteratorили другой well-known symbol.
А вот без Symbol можно обойтись, когда:
- Вам просто нужен ключ объекта и никакого риска коллизий нет. Строка проще и в логах отображается как есть.
- Вы хотите «приватные» поля. Свойства с символьными ключами на самом деле не приватные —
Object.getOwnPropertySymbolsих прекрасно находит. Для настоящей приватности берите#private-поля классов. - Вы храните данные, которые должны пережить
JSON.stringify. Не переживут.
Большая часть JS-кода спокойно живёт, ни разу напрямую не написав Symbol(...). Но как только вам захочется, чтобы ваш объект работал с for...of или естественно вёл себя при приведении типов — символы и есть та самая дверь в эту внутреннюю кухню.
Дальше: объявление функций
Символы, итераторы и генераторы плотно завязаны на функции — методы, лежащие под Symbol.iterator, фабрики, возвращающие итераторы, генераторы, у которых форма function* прячет бóльшую часть рутины. Функции — тема следующей главы; начнём с того, какими способами их вообще можно объявлять и чем эти способы отличаются друг от друга.
Часто задаваемые вопросы
Что такое Symbol в JavaScript?
Symbol — это примитивный тип, значения которого гарантированно уникальны. Создаётся через Symbol() или Symbol('описание'), и два вызова никогда не вернут равные между собой символы. В основном символы используют в двух случаях: как ключи свойств, которые не конфликтуют с чужим кодом, и как «крючки» для встроенных механизмов языка через well-known символы вроде Symbol.iterator.
Когда стоит использовать Symbol вместо строкового ключа?
Когда нужно добавить свойство объекту и при этом гарантированно не столкнуться с чужим ключом: библиотеки навешивают метаданные, фреймворки помечают объекты, а вы можете скрыть ключ от публичного API. Для обычных свойств, где важнее читаемость и коллизий всё равно не будет, строки по-прежнему удобнее.
Зачем нужен Symbol.iterator?
Symbol.iterator — это well-known символ, по которому for...of, spread-оператор и деструктуризация понимают, как перебирать объект. Достаточно определить метод по ключу Symbol.iterator, возвращающий итератор, и объект становится итерируемым. Именно так под капотом работают массивы, строки, Map и Set.
Чем отличаются Symbol() и Symbol.for()?
Symbol('x') каждый раз создаёт новый символ — два вызова с одинаковым описанием всё равно дадут разные значения. А Symbol.for('x') сначала смотрит в глобальный реестр и возвращает один и тот же символ по одному и тому же ключу в рамках всей программы. Symbol.for подходит, когда символ нужно шарить между модулями или realm'ами, а Symbol() — для локальной уникальности.