Chapter 9

BigInt and Symbol: The Design Philosophy Behind Two Special Types

BigInt didn't officially enter the ECMAScript standard until 2020 (ES2020), meaning JavaScript spent 25 years without a way to handle integers larger than 2^53. Symbol has existed since ES2015, yet remains one of the most underestimated features of the language.

🔹 Level 1 · What You Need to Know

BigInt: Handling Arbitrarily Large Integers

// Ways to create a BigInt
const a = 42n;                           // literal (suffix n)
const b = BigInt(42);                    // function call
const c = BigInt("9007199254740993");    // from string (safe!)
const d = BigInt("0xff");               // hexadecimal string

// Type distinction from Number
typeof 42n    // "bigint"
typeof 42     // "number"

// BigInt can represent integers of arbitrary precision
const huge = 9007199254740993n;  // Number cannot represent this exactly
console.log(huge);               // 9007199254740993n (correct!)

// Basic arithmetic (only with other BigInts)
10n + 20n    // 30n
10n * 20n    // 200n
10n ** 3n    // 1000n
10n / 3n     // 3n (integer division, truncates toward zero)
10n % 3n     // 1n
-10n / 3n    // -3n (truncates toward zero)

Operations BigInt does not support:

// ❌ Cannot mix with Number
1n + 1     // TypeError: Cannot mix BigInt and other types
1n + "1"   // TypeError

// ✅ Explicit conversion required
Number(1n) + 1   // 2 (converts to Number, but may lose precision!)
1n + BigInt(1)   // 2n (converts to BigInt)

// ❌ BigInt doesn't support decimals
1.5n           // SyntaxError
BigInt(1.5)    // RangeError: The number 1.5 is not safe to convert to a BigInt

// ❌ BigInt cannot be used with Math functions
Math.max(1n, 2n)  // TypeError

// ❌ JSON.stringify cannot serialize BigInt
JSON.stringify(42n)  // TypeError: Do not know how to serialize a BigInt

// ✅ Custom serialization
BigInt.prototype.toJSON = function() { return this.toString(); };
JSON.stringify({ id: 42n })  // '{"id":"42"}'

When to use BigInt:

// 1. Blockchain/cryptocurrency — 64-bit or 256-bit large integers
const maxUint256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935n;

// 2. Precise timestamps (microseconds/nanoseconds)
const nanoTimestamp = BigInt(Date.now()) * 1000000n;  // ms to ns

// 3. 64-bit integer IDs (Snowflake IDs from backend, etc.)
const snowflakeId = BigInt("1234567890123456789");

// 4. Large number arithmetic (cryptography, number theory)
function factorial(n) {
  if (n <= 1n) return 1n;
  return n * factorial(n - 1n);
}
factorial(50n)  // 30414093201713378043612608166979581188299763898377856000000000000n

Symbol: Guaranteed Unique Keys

// Symbol creation — each call produces a new unique value
const s1 = Symbol();
const s2 = Symbol();
s1 === s2   // false! Each Symbol is unique

// Optional description (for debugging only, doesn't affect uniqueness)
const s3 = Symbol("myKey");
const s4 = Symbol("myKey");
s3 === s4   // false! Same description, still different Symbols

s3.toString()   // "Symbol(myKey)"
s3.description  // "myKey"

Symbol as object key (preventing property name collisions):

// Scenario: a library adds metadata to user objects without overwriting existing properties
const USER_META = Symbol("userMeta");

function addMetaToUser(user) {
  user[USER_META] = {
    lastModified: Date.now(),
    modifiedBy: "system"
  };
}

const user = { name: "Alice", id: 1 };
addMetaToUser(user);

// Symbol keys don't appear in ordinary enumeration
Object.keys(user)         // ["name", "id"] (no Symbol key)
Object.values(user)       // ["Alice", 1]
JSON.stringify(user)      // '{"name":"Alice","id":1}' (Symbol ignored)
for (const key in user) {} // Symbol keys not iterated

// Need dedicated methods to access Symbol keys
Object.getOwnPropertySymbols(user)  // [Symbol(userMeta)]
user[USER_META]                      // { lastModified: ..., modifiedBy: "system" }
Reflect.ownKeys(user)                // ["name", "id", Symbol(userMeta)]

Global Symbol Registry:

// Symbol.for looks up or creates in the global registry
const s5 = Symbol.for("app.config");
const s6 = Symbol.for("app.config");
s5 === s6   // true! Same key returns the same Symbol

Symbol.keyFor(s5)  // "app.config" (look up key in global registry)
Symbol.keyFor(Symbol("test"))  // undefined (local Symbol, not in registry)

🔸 Level 2 · How It Actually Runs

BigInt Internal Implementation: Multi-Precision Integers

BigInt in V8 uses a multi-digit array, where each "digit" is a 64-bit unsigned integer:

BigInt memory layout (example: 2^64 + 5):

┌───────────────────────────────────────────────────────┐
│ BigInt object (heap allocated)                        │
│                                                       │
│  Map pointer  → BigInt Hidden Class                   │
│  length: 2    → uses 2 64-bit digits                  │
│  sign: 0      → positive                              │
│                                                       │
│  Digit array (little-endian):                         │
│  [0]: 5       → low 64 bits = 5                       │
│  [1]: 1       → high 64 bits = 1 (the 2^64 factor)   │
│                                                       │
│  Value = 1 × 2^64 + 5 = 18446744073709551621          │
└───────────────────────────────────────────────────────┘

Comparison: Number 42 in V8:
┌───────────────────────────────────────────────────────┐
│ SMI (small integer, embedded in pointer)              │
│ Stored directly in upper 63 bits of pointer           │
│ Zero heap allocation                                  │
└───────────────────────────────────────────────────────┘

BigInt performance cost:

Addition performance comparison (approximate, based on V8 benchmarks):

Number addition (in Smi range):
  ~0.3 ns/op (direct CPU instruction, no memory allocation)

Number addition (HeapNumber):
  ~1-2 ns/op (requires heap allocation)

BigInt addition (small BigInt, 1-2 digits):
  ~10-30 ns/op (heap allocation + multi-precision algorithm)
  Approximately 10-50x slower than Number

BigInt multiplication (large BigInt, n digits):
  O(n^1.585) time complexity (Karatsuba algorithm)
  For 256-bit integers (4 digits): ~100-500 ns/op

Note: as V8 optimizes BigInt support, these numbers will change.
Actual performance depends on BigInt size and specific operation.

Symbol Internal Implementation: Unique ID System

Symbol internal structure (conceptual):

Each Symbol in the engine has a unique numeric ID (incrementing counter)

When Symbol() is called:
  1. Read global counter nextSymbolId (starts at 0)
  2. Create a Symbol object with [[Description]] and [[SymbolId]]
  3. Increment nextSymbolId

Symbol comparison (=== operation):
  Compares [[SymbolId]] of the two Symbols
  O(1) integer comparison

Symbol.for() global registry:
  Maintains a Map<string, Symbol> (globally unique)
  Symbol.for("key") → look up in Map; if found return it, else create and store

13 Well-Known Symbols (spec built-ins):
  Symbol.iterator          — makes objects iterable (for...of)
  Symbol.asyncIterator     — async iteration (for await...of)
  Symbol.toPrimitive       — object-to-primitive conversion (see ch06)
  Symbol.toStringTag       — label for Object.prototype.toString
  Symbol.hasInstance       — custom instanceof behavior
  Symbol.isConcatSpreadable— spread behavior in Array.prototype.concat
  Symbol.species           — constructor for derived objects
  Symbol.match             — String.prototype.match behavior
  Symbol.matchAll          — String.prototype.matchAll behavior
  Symbol.replace           — String.prototype.replace behavior
  Symbol.search            — String.prototype.search behavior
  Symbol.split             — String.prototype.split behavior
  Symbol.unscopables       — properties excluded from with bindings

Well-Known Symbols in Action

// Symbol.iterator: make any object iterable
const range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from;
    const last = this.to;
    return {
      next() {
        if (current <= last) {
          return { value: current++, done: false };
        }
        return { value: undefined, done: true };
      }
    };
  }
};

for (const n of range) {
  console.log(n);  // 1, 2, 3, 4, 5
}
[...range]         // [1, 2, 3, 4, 5]
Array.from(range)  // [1, 2, 3, 4, 5]

// Symbol.toStringTag: customize Object.prototype.toString result
class MyCollection {
  get [Symbol.toStringTag]() {
    return "MyCollection";
  }
}
Object.prototype.toString.call(new MyCollection())
// "[object MyCollection]" (normally would be "[object Object]")

// Symbol.hasInstance: customize instanceof behavior
class EvenNumber {
  static [Symbol.hasInstance](num) {
    return Number.isInteger(num) && num % 2 === 0;
  }
}
2 instanceof EvenNumber   // true
3 instanceof EvenNumber   // false
4 instanceof EvenNumber   // true

🔺 Level 3 · How the Spec Defines It

6.1.5 The Symbol Type

Spec text (ECMA-262, Section 6.1.5):

6.1.5 The Symbol Type

The Symbol type is the set of all non-String values that may be used as the key of an Object property (6.1.7).

Each possible Symbol value is unique and immutable.

Each Symbol value immutably holds an associated value called [[Description]] that is either undefined or a String value.

6.1.9 The BigInt Type

Spec text (ECMA-262, Section 6.1.9):

6.1.9 The BigInt Type

The BigInt type represents an integer value. The value may be any size and is not limited to a particular bit-width. Generally, where not otherwise noted, operations are designed to return exact mathematically-based answers. For binary operations, BigInts act as two's complement binary strings, with negative numbers treated as having infinite precision leading 1 bits.

Key statements:

  1. "any size and is not limited to a particular bit-width" — BigInt is a true arbitrary-precision integer with no bit-width limit.
  2. "exact mathematically-based answers" — BigInt operations produce exact mathematical integer results, unlike Number which has floating-point error.
  3. "two's complement binary strings" — for bitwise operations, negative numbers are treated as infinitely-wide two's complement.

Complete Well-Known Symbols Table

Section 6.1.5.1 of the spec lists all Well-Known Symbols:

Symbol Name Spec Description Used In
@@asyncIterator A method returning the default AsyncIterator for an object for await...of
@@hasInstance A method that determines if a constructor recognizes an object as its instance instanceof
@@isConcatSpreadable A Boolean indicating whether Array.prototype.concat should flatten the object Array.concat
@@iterator A method returning the default Iterator for an object for...of, spread
@@match A RegExp method matching against a string String.match
@@matchAll A RegExp method returning an iterator of all matches String.matchAll
@@replace A RegExp method replacing matched substrings String.replace
@@search A RegExp method returning the index of a match String.search
@@species A function-valued property: the constructor for derived objects Array.map, etc.
@@split A RegExp method splitting a string at match indices String.split
@@toPrimitive A method converting an object to a primitive value type coercion (ch06)
@@toStringTag A String property used in the default string description Object.toString
@@unscopables An object whose property names are excluded from with bindings with statement

💎 Level 4 · Edge Cases and Traps

Trap 1: new BigInt() Throws TypeError

// ❌ BigInt is not a constructor, cannot use new
new BigInt(42)    // TypeError: BigInt is not a constructor

// ✅ Call as a function
BigInt(42)        // 42n

// Why is it designed this way?
// Symbol has the same restriction:
new Symbol()   // TypeError: Symbol is not a constructor
Symbol()       // correct, returns a Symbol value

// Design rationale:
// BigInt and Symbol are primitive types.
// Using new would create wrapper objects, causing inconsistencies:
// new Boolean(false) is a truthy object — if (new Boolean(false)) {} enters the branch
// The spec deliberately avoids BigInt and Symbol wrapper object constructors.

Trap 2: Why 1n + 1 Throws TypeError

// ❌ BigInt and Number cannot be mixed
1n + 1     // TypeError: Cannot mix BigInt and other types, use explicit conversions

// This is an intentional design decision:
// 1. Precision safety: if mixing were allowed, Number's precision limits
//    would silently corrupt results
//    BigInt(Number.MAX_SAFE_INTEGER) + 1n → implicit conversion would lose precision
// 2. Type clarity: forcing explicit conversion makes code intent unambiguous

// ✅ Correct: explicit conversion
Number(1n) + 1      // 2 (BigInt → Number, may lose precision!)
1n + BigInt(1)      // 2n (Number → BigInt, safe, but Number must be integer)

// ❌ BigInt(1.5) throws
BigInt(1.5)         // RangeError: The number 1.5 cannot be converted to a BigInt

// Comparison operators do allow mixing (no arithmetic involved)
1n < 2      // true
1n == 1     // true (loose equality, spec special-cases this)
1n === 1    // false (strict equality, different types)

Trap 3: Symbol Keys Disappear in JSON

const KEY = Symbol("key");
const obj = {
  normal: "visible",
  [KEY]: "invisible",
  123: "visible too"
};

// JSON.stringify only serializes: string keys + numeric keys (converted to strings)
JSON.stringify(obj)  // '{"123":"visible too","normal":"visible"}'
// Symbol keys vanish completely! No warning, no error.

// Spread preserves Symbols
const clone = { ...obj };
clone[KEY]  // "invisible" (spread preserves Symbol keys)

// Object.assign also preserves Symbols
const target = {};
Object.assign(target, obj);
target[KEY]  // "invisible"

// Only JSON.stringify and for...in ignore Symbol keys

Custom JSON serialization including Symbol values:

// When you need to serialize objects with Symbol keys, handle it manually
function serializeWithSymbols(obj) {
  const symbols = Object.getOwnPropertySymbols(obj);
  const result = { ...obj };  // copy ordinary properties
  for (const sym of symbols) {
    // Use description as string key (ensure uniqueness in practice!)
    result[`__symbol__${sym.description}`] = obj[sym];
  }
  return JSON.stringify(result);
}

Trap 4: Symbol.iterator for Custom Iteration

// Making a plain object iterable
class Fibonacci {
  constructor(limit) {
    this.limit = limit;
  }

  [Symbol.iterator]() {
    let prev = 0, curr = 1;
    const limit = this.limit;

    return {
      next() {
        if (prev > limit) {
          return { value: undefined, done: true };
        }
        const value = prev;
        [prev, curr] = [curr, prev + curr];
        return { value, done: false };
      },
      [Symbol.iterator]() { return this; }  // make the iterator itself iterable
    };
  }
}

const fib = new Fibonacci(100);
[...fib]  // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

// for...of loop
for (const n of new Fibonacci(20)) {
  console.log(n);  // 0, 1, 1, 2, 3, 5, 8, 13
}

// Destructuring also uses Symbol.iterator
const [a, b, c] = new Fibonacci(100);  // a=0, b=1, c=1

Trap 5: Difference Between Symbol.for and Symbol() Across Scopes

// Symbol.for creates globally shared Symbols
const s1 = Symbol.for("shared");

// In module B:
const s2 = Symbol.for("shared");  // gets the exact same Symbol as s1

s1 === s2  // true

// Symbol() creates locally unique Symbols
const s3 = Symbol("local");
const s4 = Symbol("local");
s3 === s4  // false (different Symbols!)

// Symbol.for's global scope covers:
// - Different JavaScript files/modules
// - Different <script> tags on the same page
// But does NOT cross:
// - Different Realms (different iframes or Workers)
// - Different vm sandboxes in Node.js

// Practical use: cross-library protocol establishment
// Library A:
const PLUGIN_KEY = Symbol.for("myapp.plugin");
// Library B (using the same string):
const PLUGIN_KEY_B = Symbol.for("myapp.plugin");
// PLUGIN_KEY === PLUGIN_KEY_B → true, plugins are correctly identified

Chapter Summary

  1. BigInt is an arbitrary-precision integer type introduced in ES2020: suitable for integers beyond 2^53-1 (blockchain IDs, 64-bit timestamps, large number arithmetic). BigInt cannot be mixed with Number (TypeError), cannot be used with Math functions, and cannot be serialized by JSON.stringify. It is approximately 10-50x slower than Number and should only be used when exact large integers are truly required.

  2. BigInt is internally implemented as an array of 64-bit integers: addition and multiplication are multi-precision operations whose time complexity grows with the number of digits (multiplication is O(n^1.585)). Small BigInts (like the 256-bit values common in blockchain) take approximately 100-500 ns per operation.

  3. Symbol guarantees uniqueness: two Symbol() calls return different Symbols even with the same description. When used as object keys, Symbol keys are invisible to JSON.stringify, for...in, and Object.keys — you need Object.getOwnPropertySymbols() or Reflect.ownKeys() to access them.

  4. ECMAScript defines 13 Well-Known Symbols: these built-in Symbols are the core interface for metaprogramming. Symbol.iterator makes any object work with for...of and spread, Symbol.toPrimitive gives precise control over type conversions, and Symbol.hasInstance customizes the instanceof logic.

  5. Symbol.for creates globally shared Symbols; Symbol() creates locally unique Symbols: use Symbol.for for cross-module/cross-library protocols (requires agreeing on a string key); use Symbol() for library-internal "private" property keys (prevents external interference). They cannot be substituted for each other, and Symbol.keyFor can distinguish the two kinds.

Rate this chapter
4.7  / 5  (41 ratings)

💬 Comments