Symbols
A symbol is a primitive value whose only property is that it is not equal to anything else, not even to another symbol created the same way. Two symbols with identical descriptions are different values, and there is no way to write a literal that produces one you already have.
That sounds useless until you use one as a property key. A symbol key cannot collide with anybody else's key, ever, which is the problem symbols were added to solve, and the middle of this page shows the language itself using them for exactly that.
Create programs/symbols.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import {
assert,
assertEquals,
assertFalse,
assertThrows,
} from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
two symbols are never the same value
Create two symbols from the same description and compare them:
Deno.test("two symbols are never the same value", () => {
const first = Symbol("id");
const second = Symbol("id");
assertEquals(first.description, "id");
assertEquals(second.description, "id");
assertEquals(first === second, false);
});
Check programs/symbols.test.ts
TS2367 [ERROR]: This comparison appears to be unintentional because the types 'typeof first' and 'typeof second' have no overlap.
assertEquals(first === second, false);
~~~~~~~~~~~~~~~~
at file:///programs/symbols.test.ts:10:18
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
The checker refuses to even ask the question, and read the types it names: not symbol and symbol, but typeof first and typeof second, two distinct one-element sets with no overlap, the same no-overlap verdict TS2367 gave string and number back on the equality page. TypeScript is emphatic that these are different values, so demonstrating the runtime fact takes a widening, the same move as ever:
Deno.test("two symbols are never the same value", () => {
const first = Symbol("id");
const second = Symbol("id");
assertEquals(first.description, "id");
assertEquals(second.description, "id");
assertEquals((first as symbol) === (second as symbol), false);
assertEquals(first === first, true);
assertEquals(typeof first, "symbol");
});
Check programs/symbols.test.ts
running 1 test from ./programs/symbols.test.ts
two symbols are never the same value ... ok (0ms)
ok | 1 passed | 0 failed (2ms)
Three facts from five assertions.
- Both symbols report the description
"id". The string you pass is for debugging only: it is available as.description, it appears inString(symbol), and it plays no part in identity. - Widened to plain
symbol, the comparison runs and answersfalse. Two symbols with the same description are unrelated values, and only a symbol compared against itself istrue. typeofanswers"symbol", the entry in the runtime sets list fromtypeofreports the sets that exist at run time that this page finally fills in.
the problem symbols solve
Property keys serve two audiences at once. Your program uses them for its own data: an order's total, a user's email. The language and every library you load use them for a second, meta-level purpose: toString, then, length, constructor. With string keys, both audiences share one namespace, and a new name in the second can break code in the first. Two methods on this page's arrays carry the scar tissue:
Deno.test("the problem symbols solve", () => {
assertEquals(typeof [].flat, "function");
assertEquals(typeof [].at, "function");
});
the problem symbols solve ... ok (0ms)
flat was going to be flatten, and at was going to be item. Adding either name to Array.prototype broke real sites, because libraries had already claimed those strings for themselves, so the committee picked different names and the shorter, better ones were lost. A symbol key would have made both a non-issue, because no library could already hold a symbol the committee had just created.
a symbol key stays out of the way
Put one symbol-keyed property next to one string-keyed property, and predict how many keys Object.keys reports:
Deno.test("a symbol key stays out of the way", () => {
const hidden = Symbol("hidden");
const record = { visible: 1, [hidden]: 2 };
assertEquals(Object.keys(record).length, 2);
});
Check programs/symbols.test.ts
running 3 tests from ./programs/symbols.test.ts
two symbols are never the same value ... ok (1ms)
the problem symbols solve ... ok (0ms)
a symbol key stays out of the way ... FAILED (8ms)
ERRORS
a symbol key stays out of the way => ./programs/symbols.test.ts:20:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- 1
+ 2
FAILURES
a symbol key stays out of the way => ./programs/symbols.test.ts:20:11
FAILED | 2 passed | 1 failed (10ms)
error: Test failed
One key, not two. The object holds both properties, and every ordinary enumeration pretends the symbol one is not there. Correct the prediction to 1 and collect the full inventory:
Deno.test("a symbol key stays out of the way", () => {
const hidden = Symbol("hidden");
const record = { visible: 1, [hidden]: 2 };
assertEquals(Object.keys(record).length, 1);
assertEquals(Object.keys(record), ["visible"]);
assertEquals(JSON.stringify(record), '{"visible":1}');
const seen: string[] = [];
for (const key in record) seen.push(key);
assertEquals(seen, ["visible"]);
assertEquals(record[hidden], 2);
assertEquals(Object.getOwnPropertySymbols(record), [hidden]);
assertEquals(Reflect.ownKeys(record).length, 2);
});
a symbol key stays out of the way ... ok (1ms)
Walk the inventory.
Object.keys,JSON.stringify, and afor...inloop all report onlyvisible. A symbol-keyed property will not appear in your JSON, your key loops, or a naive deep copy.record[hidden]reads2without ceremony: holding the symbol is holding the key.- Two functions do see it:
Object.getOwnPropertySymbolsreturns the symbol keys, andReflect.ownKeyscounts both kinds.
out of the way is not hidden
Before symbol keys start looking like a privacy feature, measure how far the invisibility goes:
Deno.test("out of the way is not hidden", () => {
const hidden = Symbol("hidden");
const record = { visible: 1, [hidden]: 2 };
const copy = { ...record };
assertEquals(copy[hidden], 2);
assertEquals(Object.assign({}, record)[hidden], 2);
});
out of the way is not hidden ... ok (0ms)
Spread and Object.assign copy own enumerable symbol keys along with everything else, so the copy answers 2 through the same symbol. Put the two steps together and the guarantee is precise: a symbol protects you from name collisions, not from access. Anyone holding the symbol can read the property, anyone can obtain it from getOwnPropertySymbols, and spread carries it into copies. For real privacy, use the closure from privacy that needs no keyword on the closures page, or a class private field.
the registry gives up uniqueness
Symbol.for looks a symbol up in a process-wide registry, creating it only if it is absent:
Deno.test("the registry gives up uniqueness", () => {
assert(Symbol.for("app.id") === Symbol.for("app.id"));
assertFalse((Symbol("app.id") as symbol) === (Symbol("app.id") as symbol));
assertEquals(Symbol.keyFor(Symbol.for("app.id")), "app.id");
assertEquals(Symbol.keyFor(Symbol("app.id")), undefined);
});
the registry gives up uniqueness ... ok (0ms)
Line against line.
- Two
Symbol.for("app.id")calls produce the very same symbol, where twoSymbol("app.id")calls never do. Symbol.keyForreports the registry key for a registered symbol, andundefinedfor an ordinary one, which is how you tell the two kinds apart.
That is the exact opposite trade from Symbol(). A registered symbol is reachable by name from anywhere, including code you did not write, so it gives up the uniqueness that made symbols worth having. Use it only when two independently loaded copies of your code must agree on one key, and namespace the string when you do, as "app.id" gestures at.
converting one is deliberately awkward
Deno.test("converting one is deliberately awkward", () => {
const s = Symbol("s");
assertEquals(String(s), "Symbol(s)");
assertEquals(s.toString(), "Symbol(s)");
assertThrows(() => "" + (s as unknown as string), TypeError);
assertThrows(() => `${s as unknown as string}`, TypeError);
});
converting one is deliberately awkward ... ok (0ms)
Explicit conversion works, and implicit conversion throws Cannot convert a Symbol value to a string, in both the + join and the template literal that the conversion and coercion page showed converting everything else without complaint. The asymmetry is deliberate, and the reason is this entry's whole point: a string is also a valid property key, so silently turning a symbol into one would take a key that cannot collide and turn it into a key that can. Failing loudly is correct.
the language hangs its hooks on symbol keys
The language uses symbol keys for its own extension points, which is the design working as intended: it can add hooks forever without taking a string your code might want, the exact disease the flatten story diagnosed. You have already met one hook, Symbol.toPrimitive, in Symbol.toPrimitive sees the hint. Two more:
Deno.test("the language hangs its hooks on symbol keys", () => {
const range = {
*[Symbol.iterator]() {
yield 1;
yield 2;
},
};
assertEquals([...range], [1, 2]);
const tagged = { [Symbol.toStringTag]: "Receipt" };
assertEquals(String(tagged), "[object Receipt]");
});
the language hangs its hooks on symbol keys ... ok (0ms)
Two hooks, two behaviors changed.
Symbol.iteratoris what makes a value work with spread andfor...of, so[...range]produces[1, 2]from a plain object. The*marks a generator, a function that can yield several values in turn; iteration is a large enough subject to deserve its own reference, iterables and iterators, and this step only needs the key it hangs on.Symbol.toStringTagrenames a value in the defaulttoString: the[object Object]that haunted the coercion page becomes[object Receipt].
Symbol.hasInstance rewrites an operator
The most surprising well-known symbol rewrites instanceof itself:
Deno.test("Symbol.hasInstance rewrites an operator", () => {
class Even {
static [Symbol.hasInstance](value: unknown) {
return typeof value === "number" && value % 2 === 0;
}
}
assert((4 as unknown) instanceof Even);
assertFalse((3 as unknown) instanceof Even);
});
Symbol.hasInstance rewrites an operator ... ok (0ms)
4 instanceof Even is true, and Even has no instances at all; no new Even() appears anywhere. instanceof is not a fixed walk of the prototype chain: it is a method call on the right-hand operand, and Symbol.hasInstance is that method. The coercion page's rule that instanceof is a question for objects still stands for values you did not rig, but this step is worth having seen before relying on instanceof meaning what you assume.
TypeScript has a type for one specific symbol
Three types are in play around symbols: symbol is any of them, typeof SYM is one particular one, and unique symbol is what the checker infers for a symbol declared with const, which is why that phrase appears in the pinned messages below:
Deno.test("TypeScript has a type for one specific symbol", () => {
const SYM = Symbol("SYM");
function needsThatSymbol(value: typeof SYM) {
return value.description;
}
assertEquals(needsThatSymbol(SYM), "SYM");
// @ts-expect-error: a new symbol is a different value
assertEquals(needsThatSymbol(Symbol("SYM")), "SYM");
const copyOf = SYM;
// @ts-expect-error: assignment widened the copy back to symbol
assertEquals(needsThatSymbol(copyOf), "SYM");
});
TypeScript has a type for one specific symbol ... ok (0ms)
Both pins draw the same message, Argument of type 'symbol' is not assignable to parameter of type 'unique symbol', and the second is the pitfall worth remembering.
needsThatSymbol(Symbol("SYM"))is rightly refused: a fresh symbol is a different value, whatever its description says, and the runtime line under the pin confirms the call would have worked only by accident of reading a matching description.needsThatSymbol(copyOf)is refused too, even thoughcopyOfholds the identical value and the binding cannot change. Copying a symbol into anotherconstwidens its type fromtypeof SYMback tosymbol, the same widening-on-assignment the unions and narrowing page measured in widening follows mutability, with a stricter outcome. The narrow type does not survive assignment, so pass the original around rather than aliasing it.
symbols or string literals for an enum
A union of symbol types and the union of string literals from a union is a union of sets solve the same problem differently:
Deno.test("symbols or string literals for an enum", () => {
const ACTIVE = Symbol("ACTIVE");
const PAUSED = Symbol("PAUSED");
type State = typeof ACTIVE | typeof PAUSED;
const state: State = ACTIVE;
assertEquals(state === ACTIVE, true);
// @ts-expect-error: a lookalike symbol is not a member of the union
const forged: State = Symbol("ACTIVE");
assertEquals((forged as symbol) === (ACTIVE as symbol), false);
});
symbols or string literals for an enum ... ok (0ms)
The pinned line is the difference.
- A symbol union cannot be satisfied by a value that merely looks correct.
Symbol("ACTIVE")reads exactly like the real constant and is refused, and the runtime line shows why the refusal is right: the forgery is not equal toACTIVE, so code that accepted it would comparefalseeverywhere it mattered. - A string union can be forged, in the sense that
"ACTIVE"typed anywhere is the same value everywhere. That is convenient in exactly the cases where it is also a risk.
Strings win on ergonomics: no imports, readable in logs, and serializable to JSON without ceremony, where a symbol key vanishes entirely, as a symbol key stays out of the way proved. Symbols win on safety. For most code the string union is the right default, and symbols earn their place where two things must stay distinguishable even though they are spelled the same.
In practice
- Use a symbol key to attach data to an object you do not own or expose a hook that cannot collide with ordinary properties.
- Do not use a symbol for privacy; spread copies it and
getOwnPropertySymbolsfinds it. - Implement well-known symbols such as
Symbol.iteratorandSymbol.toPrimitiveinstead of inventing equivalent protocols. - Prefer
Symbol()toSymbol.for, and namespace a key that must use the registry. - Use
String(symbol)explicitly; concatenation throws. - Remember that a
typeof SYMtype query does not follow a symbol copied into another variable.