BigInt JSON.stringify — The Hidden Serialization Bug
JSON.stringify throws TypeError for BigInt, silently dropping fields.
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
- Symbol creates guaranteed-unique property keys, even when strings collide
- BigInt handles integers beyond Number.MAX_SAFE_INTEGER (2^53-1) without precision loss
- Well-known Symbols like Symbol.iterator enable for…of loops and protocol hooks
- BigInt operations with regular Number throw TypeError — mixed math is silent failure
- JSON.stringify silently drops BigInt values — use custom replacer in production
Imagine every locker in a school has a name tag. Two kids named 'Alex' might grab the wrong locker — that's a naming collision. Symbol gives every locker a truly one-of-a-kind invisible serial number, so no two ever clash, even if they share the same label. BigInt is like a calculator that never runs out of digits — regular JavaScript numbers max out around 9 quadrillion, but BigInt can handle numbers as large as your RAM allows, perfect for cryptography or working with 64-bit IDs from databases.
Symbol and BigInt fix two of JavaScript's most painful blind spots: identity collisions and integer overflow. Without them, object property keys are always strings, forcing fragile naming conventions, and all numbers silently lose precision past 2^53. Master both, or your maps break, your crypto libraries lie, and your JSON silently corrupts data across modules.
What is Symbol and BigInt in JavaScript?
Symbol and BigInt are the two primitives added to JavaScript after the original six. Symbol, from ES2015, is a unique and immutable value used primarily as object property keys. BigInt, from ES2020, represents integers of arbitrary precision — it can hold values larger than 2^53-1 without rounding.
Let's see them in action. The code below creates a Symbol and a BigInt, uses a Symbol as a property key, and demonstrates BigInt arithmetic.
- Every
Symbol()call creates a new, never-before-seen value. - Same Symbol description does not imply equality — they are distinct.
- String keys are like name tags — duplicates cause collisions.
- Symbol keys are like serial numbers — always unique.
- BigInt is like a ruler that never ends — it doesn't round.
Symbol() call as an entirely new identity.Symbol.for() only when cross-module sharing is intended.Well-Known Symbols: How JavaScript Uses Symbol Internally
JavaScript itself uses Symbol for a dozen built-in protocols. These are accessed via Symbol.iterator, Symbol.hasInstance, Symbol.toStringTag, Symbol.match, and more. They allow user-defined objects to integrate seamlessly with language features.
For example, Symbol.iterator is the property that makes an object iterable. When you write for (const x of obj), JavaScript looks for obj[Symbol.iterator]. If it finds a function, it calls it to get an iterator. The same mechanism works with spread syntax (...) and destructuring.
Another one: Symbol.toPrimitive lets you control how an object is converted to a primitive — useful when you want custom coercion logic for BigInt or Number interactions.
BigInt Arithmetic and Type Mixing: The Silent Failure Trap
BigInt supports all standard arithmetic operators: +, -, , /, %, and *. But there's a hard rule: you cannot mix BigInt and Number in the same expression without explicit conversion. Doing so throws a TypeError immediately.
That seems straightforward — until you debug production code where a database query returns a mix of BigInt IDs and Number counts. The error is thrown at the point of operation, but the root cause may be far upstream where a property was expected to be a number but was actually a BigInt.
Division is another trap. BigInt division truncates toward zero — there is no fractional part. 5n / 2n is 2n, not 2.5. If you need fractions, convert to Number or use a decimal library.
Comparisons (==, <, >) between BigInt and Number work and return the expected boolean — but beware of == vs === . 1n == 1 is true, but 1n === 1 is false because the types differ.
Symbol Registry and Shared Symbols Across Modules
By default, each Symbol() call creates a brand new symbol. But what if you need the same symbol across multiple modules or files? Use Symbol.for(key) — it creates a symbol that is stored in a global registry. Calling Symbol.for('app.id') in two different modules returns the exact same symbol.
This is useful for well-known protocols, plugin systems, or cross-cutting metadata keys. However, it also introduces coupling: any module can access or overwrite the key's mapping. Use the registry deliberately, never by accident.
Symbol.keyFor(symbol) returns the key string for a registered symbol, which helps debugging but also exposes the key. Don't rely on it for security.
Symbol() for truly private-like keys internal to a single module. Use Symbol.for() when you need a well-known constant shared across your application — like plugin hooks or event types.Symbol.for() is global — any code can collide with your key.Symbol() gives local uniqueness.Symbol.for() gives global uniqueness.Serializing Symbol and BigInt: JSON, structuredClone, and Beyond
Symbol-keyed properties are invisible to JSON.stringify — they are simply omitted. BigInt values cause JSON.stringify to throw a TypeError unless a replacer function is provided. This is a common source of silent data loss in APIs.
The structuredClone() API (available in modern browsers and Node.js 17+) handles Symbols by throwing a DOMException. It cannot clone Symbol-keyed properties or BigInt values. If you need to clone objects containing these types, implement a custom deep clone or use libraries like lodash's cloneDeep with a customizer.
For safe serialization, always provide a toJSON method on BigInt and ensure your JSON replacer handles both Symbol and BigInt appropriately. For Symbol keys, consider converting them to strings with a prefix like '__sym_' before serialization, and restore them on read.
BigInt Operators: The Type-Mixing Ambush That Will Break Your Build
BigInt operators look like Number operators but silently refuse type mixing. You cannot add 1n + 1. That throws TypeError. The same applies to bitwise shifts, exponentiation, and modulo. The WHY: JavaScript preserves precision by refusing implicit coercion. Production incidents happen when a REST API returns a string, your parser returns a Number, and your arithmetic mixes types. Coerce explicitly with Number() or BigInt() — but only when you know precision won't clip. Comparisons (>, <, >=, <=) are the sole exception: they allow mixed types and return boolean. Use them to validate range boundaries before arithmetic. Never assume operand types after a fetch. Always guard with typeof checks or a runtime type validator.
Serialization Showdown: JSON.stringify’s BigInt Blind Spot
JSON.stringify throws on BigInt values. There is no built-in toJSON for BigInt. The WHY: JSON is language-agnostic, and BigInt is a JavaScript-specific primitive. You must serialize manually — usually to string. The recommended pattern: override toJSON on BigInt.prototype if you control the environment, or pass a replacer function to stringify. For structuredClone, BigInt survives the round trip — but only if you clone a BigInt directly. If your BigInt lives inside an object, structuredClone serializes it to BigInt without issue. The trap: deserialization with JSON.parse returns a string. You must convert back with BigInt(). Forget that step and your arithmetic silently produces wrong results when compared with loose equality. Always log the original type before serialization as a debugging anchor.
The Hidden Serialization Bug: BigInt Lost in JSON
function() { return this.toString(); }.- Never assume JSON.stringify handles non-standard primitives — test serialization paths with real data.
- Add custom toJSON to BigInt if your app passes it through JSON anywhere.
- Add a linter rule to flag BigInt properties used in API responses without explicit serialization.
Object.keys() does not return Symbol-keyed propertiesfunction() { return this.toString(); } before any serialization. Verify with a test that logs the stringified output.console.log(Object.getOwnPropertySymbols(obj))console.log(Reflect.ownKeys(obj))Key takeaways
Symbol() call is distinct, Symbol.for() shares across modules.Common mistakes to avoid
5 patternsAssuming Symbol('desc') === Symbol('desc')
Symbol.for() if the symbol needs to be shared across modules by key.Using BigInt in JSON.stringify without toJSON
function() { return this.toString(); }; early in application startup. Optionally add a JSON.stringify replacer for custom handling.Mixing BigInt and Number without explicit conversion
Expecting Object.keys or for…in to reveal Symbol keys
Using structuredClone on objects with BigInt or Symbol
Interview Questions on This Topic
What is a Symbol in JavaScript? How is it different from a string key?
Symbol() — every call returns a unique, immutable value. Unlike string keys, Symbol keys are guaranteed not to collide with any other key, even if the same description is used. Symbols are not enumerable in for…in loops, and they are hidden from Object.keys and JSON.stringify. Use them for metaprogramming, well-known protocols (Symbol.iterator), and adding metadata to objects without risk of name clashes.Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Lessons pulled from things that broke in production.
That's Advanced JS. Mark it forged?
4 min read · try the examples if you haven't