JavaScript Basics: Use Symbol Keys without Mistaking Them for Privacy
String property names work well for everyday data. A Symbol gives a property a distinct identity when separate pieces of code need to attach information without accidentally choosing the same key. Its description is a label for people, not the identity itself.
These three independent examples were tested on Linux with Node.js v24.19.0. Built-in assertions check collisions, copying, and registry lookup. No packages, files beyond the examples, or network requests are needed.
Prepare a scratch directory
You need Bash, a text editor, Node.js 24 on your PATH, and the mktemp, rm, and rmdir utilities. Keep the same terminal open. Save each named source file as UTF-8 in this directory; .mjs selects ES modules. The setup stops if creating or entering the scratch directory fails.
node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1The tested runtime prints:
v24.19.01. Create different keys with the same description
Save this as distinct-symbol-keys.mjs:
import assert from 'node:assert/strict';
const firstStatus = Symbol('status');
const secondStatus = Symbol('status');
const job = {
status: 'public',
[firstStatus]: 'queued',
[secondStatus]: 'reviewed',
};
assert.notEqual(firstStatus, secondStatus);
assert.equal(job[firstStatus], 'queued');
assert.equal(job[Symbol('status')], undefined);
assert.equal(job.status, 'public');
console.log(`String key: ${job.status}`);
console.log(`First symbol: ${job[firstStatus]}`);
console.log(`Second symbol: ${job[secondStatus]}`);Run it:
node distinct-symbol-keys.mjsOutput:
String key: public
First symbol: queued
Second symbol: reviewedEach Symbol('status') call creates a new symbol, even with identical descriptions. Brackets use the actual symbol as a property key. The ordinary string key status remains separate from both symbol keys.
Keep the created symbol somewhere you can reuse it. Reconstructing the description does not reconstruct the key, as the missing lookup demonstrates. Use Symbol() without new. The ECMAScript Symbol specification defines this identity behavior.
2. Inspect what enumeration and copying retain
Save this as inspect-symbol-keys.mjs:
import assert from 'node:assert/strict';
const review = Symbol('review');
const record = { title: 'Guide', [review]: 'checked' };
const copy = { ...record };
assert.deepEqual(Object.keys(record), ['title']);
assert.deepEqual(Object.getOwnPropertySymbols(record), [review]);
assert.deepEqual(Reflect.ownKeys(record), ['title', review]);
assert.equal(copy[review], 'checked');
assert.equal(JSON.stringify(record), '{"title":"Guide"}');
console.log(`Object.keys: ${Object.keys(record).join(', ')}`);
console.log(`Reflect.ownKeys: ${Reflect.ownKeys(record).map(String).join(', ')}`);
console.log(`Copied symbol value: ${copy[review]}`);
console.log(`JSON: ${JSON.stringify(record)}`);Run it:
node inspect-symbol-keys.mjsOutput:
Object.keys: title
Reflect.ownKeys: title, Symbol(review)
Copied symbol value: checked
JSON: {"title":"Guide"}Object.keys() lists enumerable own string keys. Object.getOwnPropertySymbols() finds own symbols, and Reflect.ownKeys() includes both kinds, even non-enumerable keys. Symbols are therefore discoverable; they do not protect secrets. See the Reflect.ownKeys reference.
Object spread copies this enumerable symbol property, while JSON serialization omits symbol-keyed properties. Do not rely on JSON to preserve an in-memory symbol annotation. A copied property is also different from a private field: anyone who obtains the symbol, including through reflection, can use it. Choose explicit string-keyed data when a value must cross a JSON boundary.
3. Share a deliberate registry key
Save this as registered-symbol-key.mjs:
import assert from 'node:assert/strict';
const writerKey = Symbol.for('tutorial.review-status');
const readerKey = Symbol.for('tutorial.review-status');
const localKey = Symbol('tutorial.review-status');
const record = { [writerKey]: 'approved' };
assert.equal(writerKey, readerKey);
assert.notEqual(writerKey, localKey);
assert.equal(record[readerKey], 'approved');
assert.equal(Symbol.keyFor(writerKey), 'tutorial.review-status');
assert.equal(Symbol.keyFor(localKey), undefined);
assert.throws(() => Symbol.keyFor('tutorial.review-status'), TypeError);
console.log(`Shared value: ${record[readerKey]}`);
console.log(`Registry name: ${Symbol.keyFor(writerKey)}`);
console.log(`Local registry name: ${Symbol.keyFor(localKey)}`);Run it:
node registered-symbol-key.mjsOutput:
Shared value: approved
Registry name: tutorial.review-status
Local registry name: undefinedSymbol.for() looks up a string in the global symbol registry and creates an entry only when needed. Code using that same registry name gets the same symbol. Symbol.keyFor() retrieves a registered symbol’s name, but returns undefined for an ordinary local symbol, even when its description matches.
Registry names intentionally permit agreement, so use a distinctive namespace and document its purpose. They are not a privacy boundary or a mechanism for exchanging an object between separate processes. For a module-internal annotation, a local symbol is usually simpler. The Symbol reference distinguishes local symbols, registered symbols, and well-known symbols used by language protocols.
Clean up
In the same terminal, remove only the named files, return to the original directory, and remove the empty scratch directory:
rm -- distinct-symbol-keys.mjs inspect-symbol-keys.mjs registered-symbol-key.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dirAcknowledgements
This post was created with assistance from AI.