JavaScript Type Coercion — Why '149.99' + Tax Broke It
String '149.99' + 0.08 tax = '149.990.08' broke a real checkout flow.
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- JavaScript has 8 data types: 7 primitives (string, number, boolean, null, undefined, Symbol, BigInt) and 1 object type (arrays, functions, objects).
- Use typeof to check type – but watch for quirks: typeof null returns 'object' (a historic bug).
- typeof function returns 'function', but functions are still objects.
- Type coercion (implicit conversion) is the #1 source of bugs – always use ===.
- null and undefined both mean 'no value' – use === to distinguish them.
JavaScript's type system is one of the most misunderstood parts of the language, and for good reason — it has genuine quirks that exist for historical reasons. Type coercion, the null/undefined split, and typeof's inconsistencies have tripped up millions of developers.
The good news is that once you understand the system on its own terms, it makes sense. This guide covers all 8 types, how typeof works, what coercion does, and how to write code that does not fall into the common traps.
Why JavaScript's Type System Is a Minefield
JavaScript's type system is dynamically typed with implicit coercion, meaning values are automatically converted between types during operations. This is not a bug — it's a design choice that trades compile-time safety for runtime flexibility. The core mechanic: when operators encounter mismatched types, the engine applies a set of precedence rules to coerce one or both operands into a common type before evaluation. The most notorious example is the + operator: if either operand is a string, it concatenates; otherwise, it performs numeric addition. This leads to '149.99' + 0.13 producing '149.990.13', not 150.12. In practice, the coercion rules follow a strict order: ToPrimitive → ToNumber → ToString, with object types triggering valueOf() or toString() first. The key property: loose equality (==) triggers coercion, while strict equality (===) does not. This matters because real systems fail silently — a tax calculation that concatenates instead of adds produces a string, which then propagates through downstream logic, corrupting totals, reports, and database writes. Understanding coercion is not optional; it's the difference between a reliable financial calculation and a silent data corruption bug.
Number() or parseFloat() before any arithmetic — never rely on implicit coercion for business logic.The 8 JavaScript Types
JavaScript groups data into two camps: primitives (immutable, passed by value) and objects (mutable, passed by reference). There are 7 primitive types: string, number, boolean, null, undefined, Symbol, BigInt. Everything else is an object — including arrays, functions, dates, and plain objects. This split is the foundation of how JavaScript behaves.
str = 'new', you're not changing the string — you're pointing the variable to a new string. The old string remains in memory until garbage collected.Object.is() for reliable equality (handles NaN and -0 correctly).null vs undefined
Both mean 'no value' but they mean it in different ways. undefined is the language's own 'not set yet'. null is your explicit signal that something was intentionally cleared. This distinction is crucial for debugging and for writing clean APIs.
Type Coercion — Where Bugs Hide
JavaScript automatically converts types in certain contexts. This is called implicit coercion. It is the source of most JS type bugs. The + operator is particularly dangerous: if either operand is a string, it concatenates. The - operator coerces both to numbers. Loose equality (==) also coerces, which is why it's almost always better to use strict equality (===).
- + with a string → concatenation (string wins)
- - , * , / → all operands coerced to numbers
- == triggers coercion on both sides
- === is the safe default — use it always
Number() or parseInt().Number(), String(), Boolean().Number(), parseInt(), or parseFloat() explicitly. Avoid unary +.Boolean() or !! (double NOT) — but prefer explicit Boolean() for clarity.Checking Types Reliably
typeof works well for primitives (with the null exception). For objects, you need additional tools: Array.isArray() for arrays, instanceof for prototype chains, and Object.prototype.toString for a precise type tag.
getType(val) that handles null and arrays.Array.isArray() for arrays.Symbol and BigInt — The Less Common Types
Symbol (ES6) creates unique, immutable identifiers. Two Symbols with the same description are never equal. BigInt (ES2020) lets you work with integers beyond Number.MAX_SAFE_INTEGER (2^53 - 1). They're less common but essential for advanced scenarios: Symbols for property keys that won't collide, BigInt for high-precision arithmetic like financial systems or 64-bit IDs.
Primitive vs Reference — The Memory War You Didn't Know You Were Fighting
Every variable you declare boils down to one thing: where its value lives in memory. Primitives (strings, numbers, booleans, null, undefined, symbol, bigint) are stored directly on the stack. When you copy a primitive, you get a brand-new, independent copy. Change one, the other stays untouched.
Objects, arrays, and functions live on the heap. Variables hold a pointer to that heap location—a reference, not the data itself. Copying an object doesn't clone it; you just get another pointer to the same spot. Mutate one variable's object and every other pointer sees the change. This is why your state managment library zeros in on immutable patterns: they avoid this shared-memory nightmare.
The production takeaway: always assume object copies are shallow. Use structuredClone() or spread operators deliberately. Never mutate function arguments that are objects unless you're ready for side effects that'll haunt your Friday deployment.
typeof Is a Liar — Here's What Actually Works for Type Checks
typeof screams 'object' for null, arrays, dates, and regexes. That's not useful—it's a trap. Null is a primitive with its own type, but typeof null === 'object' is a bug from JavaScript's first day that will never be fixed. Arrays aren't objects for iteration purposes; they're arrays. You need real detection.
For strict primitive checks, use Object.prototype.toString.call(). It returns strings like '[object Array]' or '[object Null]', and it never lies. Pair it with a small utility: typeOf(value) that strips the noise. For arrays, Array.isArray() is your friend—works cross-realm, even across iframes. For plain objects, check Object.getPrototypeOf(value) === Object.prototype.
Don't rely on duck-typing in critical paths. When an API returns a payload, validate the types before you operate. One unexpected null slipping through typeof costs you a 'Cannot read properties of null' stack trace at 3 AM.
Object.prototype.toString.call() as your single source of truth for type detection. Wrap it once, forget debugging typeof edge cases.Object.prototype.toString.call() or Array.isArray() for reliable type checks in production.Keyed Collections: When Objects Lie
Objects force all keys to strings, so obj[true] becomes obj['true'] and obj[{}] becomes obj['[object Object]']. This breaks any code relying on numeric or object keys. Maps and Sets fix this with zero coercion. Use a Map when you need keys of any type—numbers, objects, even NaN—and need predictable insertion order. Use a Set when you only care about unique values, not keys. WeakMap and WeakSet are the memory-safe siblings: they hold weak references to objects, which means if no other code references a key, the garbage collector can reclaim it. This prevents memory leaks in long-running apps. The catch: WeakMaps aren't iterable. You can't .forEach or get their size. They're purpose-built for private data or DOM node metadata—never for general iteration.
Interesting Facts About Data Types
JavaScript’s type system hides chaos behind a clean syntax. typeof null returns 'object' — a bug from the first spec that can’t be fixed without breaking millions of sites. NaN is the only value not equal to itself: NaN !== NaN is true. Arrays are objects: typeof [] returns 'object'. That’s why Array.isArray() exists. Strings are primitive but behave like objects because JS autoboxes them with temporary wrapper objects. This is why 'hello'.length works — JS creates a String object, reads .length, then discards it. BigInt can’t mix with regular numbers: 1n + 1 throws a TypeError. undefined is a global variable that can actually be assigned (in old JS), while null is a keyword. The classic interview trap: 0.1 + 0.2 !== 0.3 — all numbers are IEEE 754 doubles, so decimal math is imprecise by design.
NaN with === — use Number.isNaN(). First checks type, avoiding false positives with non-numbers.Summary
JavaScript's type system is deceptive at every turn. What appears to be a simple 'number' can silently overflow into a BigInt, and what looks like a string can coerce into NaN during runtime. The core tension lies between primitive immutability (undefined, null, Boolean, Number, String, Symbol, BigInt) and reference mutability (Object, Array, Function, Date, RegExp, Set, Map, WeakMap, WeakSet). Memorable rules: null is an object (by spec bug), undefined is a global property, and type coercion is never 'helpful'—it's a source of production bugs. Always use strict equality (===) for comparisons, check types with Object.prototype.toString.call(value) for reliability, and treat mutable objects as stateful hazards. The language punishes assumptions; the only safe approach is explicit type handling and defensive coding around falsy values, NaN, and unexpected reference mutations.
Object.prototype.toString.call() for reliable type detection; never trust typeof.Real-World Type Gotchas
Beyond the textbook types, JavaScript punishes developers with edge cases that burn production systems daily. Array.isArray() is your only reliable way to detect arrays—typeof returns 'object'. NaN !== NaN, so you need Number.isNaN() for that check. Type coercion in comparisons: [] == false evaluates to true (empty array becomes empty string, then 0, then false), but [] == ![] is also true (truth table loophole). The infamous 'parseInt(0.0000005)' returns 5 because the string becomes '5e-7' and parseInt stops at '5'. Always pass radix: parseInt(x, 10). For BigInt, mixing with regular Number types throws TypeError—you cannot add 1n + 1. These nine gotchas account for 70% of type-related bug reports in production JavaScript. Know them, and you'll save hours of debugging.
Array.isArray(). Always use strict equality and Number.isNaN() for NaN.The $1000 Bug: A String That Looked Like a Number
Number() or parseFloat() before arithmetic. Use === to avoid accidental coercion.- Never assume input types are what they appear to be.
- Explicit conversion > implicit coercion every time.
- Use
Number.isNaN()to validate parsed results.
Number() to convert strings explicitly before addition.Array.isArray() returns false for objects that behave like arraysconsole.log('value:', a, 'typeof:', typeof a, 'isNaN:', Number.isNaN(a))console.log('parsed:', Number(a)) // see what Number() doesNumber.isNaN() to reliably detect NaN. Never use global isNaN() because it coerces.Key takeaways
Array.isArray() to check for arraysNumber(), String(), Boolean()) is safer than implicit coercion.Common mistakes to avoid
4 patternsUsing == for comparison
Forgetting that typeof null === 'object'
Assuming array is an object type without checking
Mixing Number and BigInt in arithmetic
Number() for small numbers or keep everything as BigInt with explicit conversion: BigInt(numberValue).Interview Questions on This Topic
What are the 8 data types in JavaScript?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's JS Basics. Mark it forged?
6 min read · try the examples if you haven't