Symbols Interview Questions & Answers
29 questions Updated 2026-06-18
JavaScript Symbol interview questions — unique primitives, the global registry, symbol keys hidden from enumeration, well-known symbols like Symbol.iterator and Symbol.toPrimitive, and metaprogramming use cases.
Read the in-depth guideJavaScript Symbols Explained — Unique Keys, the Global Registry and Well-Known SymbolsA Symbol is a primitive type (added in ES6) whose every value is
guaranteed unique. You create one by calling Symbol().
const a = Symbol()
const b = Symbol()
a === b // false always unique, even with no description
typeof a // 'symbol'
Their main use is as collision-proof object property keys and as "well-known" hooks that customize language behavior.
The optional string passed to Symbol('desc') is a debug label only —
it does not affect identity. Two symbols with the same description are
still different.
const s = Symbol('userId')
s.description // 'userId' read-only label
Symbol('x') === Symbol('x') // false
Use it to make logs and toString() output readable; never rely on it for
equality.
Symbol is not a constructor — symbols are primitives, not objects, so
new Symbol() throws a TypeError.
const s = Symbol('ok') //
const bad = new Symbol() // TypeError: Symbol is not a constructor
This deliberately prevents accidental Symbol wrapper objects. If you ever
need the wrapper, Object(symbol) does it, but you rarely should.
Use computed property syntax ([sym]) in a literal, or bracket
assignment. The key is then accessible only by holding that exact symbol.
const id = Symbol('id')
const user = { [id]: 123, name: 'Ada' }
user[id] // 123
user.id // undefined — 'id' string is a different key
Because the symbol is unique, this key can never clash with any string key or another library's symbol.
When you attach metadata to objects you don't own (or that mix data from many sources), a symbol key is guaranteed not to overwrite or be overwritten by anyone else's key.
const CACHE = Symbol('cache')
function memoize(obj) { obj[CACHE] = compute() } // won't clash
A string key like obj.cache risks stomping a real property; the symbol
can't, because no other code holds that symbol.
Symbol keys are skipped by for...in, Object.keys, Object.values,
Object.entries, and JSON.stringify — they're effectively hidden from
ordinary enumeration.
const s = Symbol('hidden')
const o = { [s]: 1, visible: 2 }
Object.keys(o) // ['visible'] symbol omitted
JSON.stringify(o) // '{"visible":2}'
This makes symbols handy for "internal" properties that shouldn't leak into serialization or iteration.
Use Object.getOwnPropertySymbols, or Reflect.ownKeys to get string
and symbol keys together.
const s = Symbol('id')
const o = { [s]: 1, name: 'x' }
Object.getOwnPropertySymbols(o) // [Symbol(id)]
Reflect.ownKeys(o) // ['name', Symbol(id)]
So symbols are not truly private — code with a reference to the object can still discover and read them.
Symbol.for(key) looks up (or creates) a symbol in a process-wide
global registry by string key — so the same key always returns the
same symbol, even across modules or realms.
Symbol.for('app.id') === Symbol.for('app.id') // true shared
Symbol('app.id') === Symbol('app.id') // false — local
Use Symbol.for when different parts of a system must agree on one symbol;
use plain Symbol() for guaranteed-unique local keys.
Symbol.keyFor(sym) returns the registry key string for a symbol
created via Symbol.for, or undefined for a non-registered symbol.
const g = Symbol.for('shared')
Symbol.keyFor(g) // 'shared'
Symbol.keyFor(Symbol('x')) // undefined — not in registry
It's the inverse of Symbol.for and the only way to recover the key of a
registered symbol.
They are built-in symbols exposed as static properties on Symbol (e.g.
Symbol.iterator, Symbol.toPrimitive) that let you hook into language
operations by defining specially-keyed methods on your objects.
const range = {
[Symbol.iterator]() { /* ... */ } // makes object iterable
}
Implementing the right well-known symbol customizes how your object behaves
in for...of, instanceof, type coercion, spreading, and more.
Defining a [Symbol.iterator]() method that returns an iterator lets your
object work with for...of, spread, and destructuring.
const nums = {
*[Symbol.iterator]() { yield 1; yield 2; yield 3 }
}
[...nums] // [1, 2, 3]
All built-in iterables (Array, Map, Set, String) implement this symbol — it is the protocol that makes iteration extensible.
It's the async counterpart of Symbol.iterator — defining
[Symbol.asyncIterator]() makes an object usable with for await...of,
where each next() returns a Promise.
const stream = {
async *[Symbol.asyncIterator]() { yield await fetchChunk() }
}
for await (const chunk of stream) { /* ... */ } //
Used for streams and paginated/async data sources where values arrive over time.
Defining [Symbol.toPrimitive](hint) lets an object control its conversion
to a primitive. The hint is 'number', 'string', or 'default'
depending on context.
const money = {
amount: 5,
[Symbol.toPrimitive](hint) {
return hint === 'string' ? `$${this.amount}` : this.amount
}
}
`${money}` // '$5' (string hint)
money * 2 // 10 (number hint)
It overrides the default valueOf/toString dance with a single, precise
hook.
It customizes the tag shown by Object.prototype.toString — the
[object X] label.
class Money {
get [Symbol.toStringTag]() { return 'Money' }
}
Object.prototype.toString.call(new Money()) // '[object Money]'
Built-ins use it too ([object Map], [object Promise]). It's mostly for
debugging and type-branding, not control flow.
Defining a static [Symbol.hasInstance](value) method overrides what
value instanceof Class returns — letting you do duck-typing checks.
class Even {
static [Symbol.hasInstance](n) { return n % 2 === 0 }
}
4 instanceof Even // true
3 instanceof Even // false
Powerful but surprising — override instanceof sparingly, since readers
assume it checks the prototype chain.
They provide soft privacy: symbol keys are hidden from enumeration and
JSON.stringify, so they won't accidentally leak — but anyone with the
symbol (or via Object.getOwnPropertySymbols) can still access them.
const _secret = Symbol('secret')
class Vault { constructor() { this[_secret] = 42 } }
For hard privacy use class #private fields, which are truly
inaccessible from outside. Symbols are for collision-avoidance, not
security.
Symbol-keyed properties are ignored entirely, and a symbol value
becomes undefined (so it's dropped from objects or turned to null in
arrays).
JSON.stringify({ [Symbol('k')]: 1, a: 2 }) // '{"a":2}' symbol key gone
JSON.stringify({ a: Symbol('v') }) // '{}' symbol value gone
JSON.stringify([Symbol('v')]) // '[null]'
So symbols never survive serialization — keep that in mind for state you need to persist.
'symbol' — it's its own primitive type alongside string, number,
boolean, bigint, undefined, and object.
typeof Symbol() // 'symbol'
typeof Symbol.iterator // 'symbol'
That distinct typeof makes symbols easy to detect, e.g. when guarding a
function that should reject symbol keys.
Symbols don't auto-coerce to strings to avoid silent bugs — implicit conversion (concatenation, template slots) throws a TypeError. You must convert explicitly.
const s = Symbol('id')
'' + s // TypeError
`${s}` // TypeError
String(s) // 'Symbol(id)' explicit
s.toString() // 'Symbol(id)'
String(sym) and .toString() are the safe, intentional conversions.
They are copied — Object.assign and object spread copy enumerable
own properties including symbol keys (symbol keys are enumerable for
copying purposes even though they're skipped by for...in/Object.keys).
const s = Symbol('id')
const src = { [s]: 1, a: 2 }
const copy = { ...src }
copy[s] // 1 symbol key copied
So spreading does preserve symbol-keyed data — a useful subtlety when you assumed they'd be dropped.
Symbols make unforgeable, collision-free constants — each is unique, so you can't accidentally match by an equal string literal.
const Status = { ACTIVE: Symbol('active'), DONE: Symbol('done') }
if (task.status === Status.ACTIVE) { /* ... */ }
Unlike string enums, a stray 'active' elsewhere won't equal
Status.ACTIVE. The tradeoff: symbols don't serialize, so use string enums
when values cross network/storage boundaries.
The global registry is shared by string key across realms (iframes,
worker boundaries, modules), so Symbol.for('x') yields a matching symbol
everywhere, whereas a plain Symbol() from one realm is unique to it.
// iframe A and iframe B both run:
Symbol.for('app.token') // same symbol identity in both
It's the mechanism for agreeing on a symbol without sharing the actual reference.
Use typeof value === 'symbol'. Avoid instanceof Symbol, which is false
for primitive symbols.
const isSymbol = v => typeof v === 'symbol'
isSymbol(Symbol()) // true
Symbol() instanceof Symbol // false primitive, not an instance
The typeof check is the only reliable test for symbol primitives.
Symbol.species lets a class specify which constructor built-in methods
like map/filter/slice use to create derived instances.
class MyArray extends Array {
static get [Symbol.species]() { return Array } // map returns Array
}
const r = new MyArray(1, 2, 3).map(x => x)
r instanceof MyArray // false — it's a plain Array
It's an advanced hook for subclassing built-ins; most code never needs it, but it explains some surprising subclass behaviors.
Call the array's [Symbol.iterator]() to get its iterator directly — the
same one for...of uses.
const arr = ['a', 'b']
const it = arr[Symbol.iterator]()
it.next() // { value: 'a', done: false }
it.next() // { value: 'b', done: false }
Useful when you want manual control over iteration or to forward an iterator to other consuming code.
Symbols from Symbol.for live in the global registry for the lifetime
of the realm and are never garbage-collected, since the registry holds
them. Plain Symbol() values are collectible like any primitive once
unreferenced.
Symbol.for('keeps.living') // retained globally
let s = Symbol('local'); s = null // eligible for GC
Prefer plain symbols unless you genuinely need cross-module sharing, to avoid unbounded registry growth.
Use #private fields for true encapsulation within a single class. Use
symbols when you need a non-enumerable key that's shareable across
modules, attachable to objects you don't own, or used in mixins.
class A { #real = 1 } // hard-private, class-scoped
const META = Symbol('meta') // shareable, attach anywhere
someExternalObj[META] = 'tag' // works on foreign objects
Privacy -> #; collision-free extensibility -> symbols.
The 'default' hint is passed for operations that don't clearly want a
number or string — chiefly + and == comparisons. You decide
what makes sense.
const temp = {
c: 20,
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.c
if (hint === 'string') return `${this.c}°C`
return `${this.c}°C` // 'default' -> treat like string here
}
}
temp + '' // '20°C' (default hint)
Most objects map 'default' to the same behavior as 'number' or
'string' depending on intent.
Use plain Symbol() for collision-free metadata keys; reserve Symbol.for
for genuinely shared cross-module symbols; don't treat symbols as a
security mechanism; and convert to string explicitly with String(sym).
const TAG = Symbol('tag') // local, unique
thirdPartyObj[TAG] = 'mine' // safe to attach
Implement well-known symbols (iterator, toPrimitive) when you want
objects to integrate with language features — that's where symbols add the
most value.
Practice tests are coming soon
Get notified when interactive mock interviews and quizzes launch.