JavaScript is dynamically typed: bindings don’t have fixed types, but values do. Language values divide into seven primitive types and objects.
Operators and conditions apply their own coercion rules: "false" is truthy, Number("") is 0, and + can add or concatenate.
Parse and validate each field by its meaning at input boundaries, distinguish nullish values from other falsy values when defaulting, and choose equality semantics deliberately.
What it is and why it exists
JavaScript uses dynamic typing : runtime values have types, while variable names don’t. A let binding can point to a number and later point to a string. The original value hasn’t changed type; the binding now points to another value. Dynamic typing lets the same syntax handle data from forms, JSON, and library calls, but code must state its type assumptions at those boundaries.
Language values divide into objects and seven primitive values : undefined, null, Boolean, Number, BigInt, String, and Symbol. Primitive values are immutable; replacing text or changing its case produces a new string. Objects can have mutable properties, and arrays and functions are objects rather than additional language types.
Assignment and argument passing both pass values. Copying a primitive gives you that primitive value. Copying an object value gives you another reference to the same object, so two bindings can observe changes to the same properties. Explaining this as “primitives live on the stack and objects live on the heap” isn’t reliable because storage is an implementation strategy, not ECMAScript semantics.
const constrains a binding: it can’t be made to point at another value, but it doesn’t freeze an object. const order = { count: 1 } still permits changing order.count. If an API needs a read-only object contract, express that through its design, copying, or freezing; don’t infer deep immutability from const.
These types most often affect business results when code checks external input, selects operator semantics, or decides whether two values are equal. The problem usually isn’t that “JavaScript has no types.” It is that code has left the input type, conversion rule, or missing-value policy implicit.
How it works
Seven primitive types and objects
undefined often means that no value was supplied, as with a missing property or a function without an explicit return. null is an empty value used explicitly by the program. An interface decides whether the two have different domain meanings; the language only specifies that they are distinct primitive values.
Boolean has only true and false. String is a sequence of UTF-16 code units. Symbol is a primitive with unique identity that can also serve as a property key. Number uses the IEEE 754 double-precision floating-point format for ordinary integers, fractions, Infinity, and NaN.
BigInt represents arbitrary-precision integers, and its literals end in n. It is a different numeric type from Number. Most arithmetic operations reject a mixture because the runtime can’t decide whether to preserve BigInt precision or use Number’s range and fractional semantics. Before comparing or converting, decide which numeric representation the domain needs.
Object is the type of every other language value. Plain objects, arrays, dates, regular expressions, maps, sets, and functions all belong here. Separate objects have separate identities even when their properties match; two references pass strict equality only when they point to the same object.
typeof is a coarse check
typeof returns one of a fixed set of strings. It is useful for distinguishing primitive types and detecting callable values, but it isn’t a general object classifier. The table shows common results and the checks to add when you need more precision.
| Value | typeof result | When a more precise check is needed |
|---|---|---|
undefined | "undefined" | Usually no extra check |
null | "object" | value === null |
| Boolean | "boolean" | Usually no extra check |
| Number | "number" | Number.isFinite(), Number.isNaN() |
| BigInt | "bigint" | Usually no extra check |
| String | "string" | Usually no extra check |
| Symbol | "symbol" | Usually no extra check |
| Function | "function" | Check the specific protocol when necessary |
| Arrays and other objects | "object" | Array.isArray() or a domain check |
typeof null === "object" is compatibility behavior retained by the language. Arrays also return "object", so an array branch must use Array.isArray(value). A “non-null object” check requires at least value !== null && typeof value === "object"; after that, validate the needed properties rather than treating every object as the same kind of record.
Conversion depends on context
Type coercion is the automatic conversion an operation performs when it needs another type. Conditions and ! use Boolean conversion; most arithmetic uses numeric conversion; interpolation and String() use string conversion. Calling Boolean(), Number(), or String() explicitly selects the corresponding rules, but “explicit” doesn’t mean the result matches your domain.
Boolean conversion has a fixed set of falsy values: false, 0, -0, 0n, "", null, undefined, and NaN. Every other language value is truthy, including "0", "false", empty arrays, and empty objects. if (value) answers “what is its truth value?” It is not a substitute for “is this field present?” or “is this array empty?”
&& and || test operand truth values but return an original operand rather than a Boolean. left && right returns left when it is falsy and otherwise returns right; left || right returns left when it is truthy and otherwise returns right. ?? chooses the right side only when the left is null or undefined, so it preserves a valid 0, false, or empty string.
Numeric conversion also has results worth defending against at boundaries. Number("") and Number(" ") are both 0, and Number(null) is 0; invalid numeric text produces NaN. parseInt() parses a prefix, so parseInt("12px", 10) is 12. That suits a syntax that explicitly permits a suffix, not validation that the whole field is a decimal integer.
Operators choose the coercion path
Subtraction, multiplication, division, and remainder first convert operands to numeric values. Addition first converts objects to primitives; if either result is a String, it concatenates, and otherwise it performs numeric addition. This makes 1 + 2 + "3" equal "33", while "1" + 2 + 3 is "123", because the expression associates from left to right.
Relational comparison can’t be reduced to “convert everything to numbers” either. After both operands become primitives, two strings are compared by their UTF-16 code units; otherwise comparison takes the numeric path. As a result, "10" < "2" is true, while Number("10") < Number("2") is false.
Loose equality == uses abstract equality comparison. It may coerce operands, but it doesn’t simply “convert both sides to the same type.” For example, null == undefined is true, while loose comparisons between either one and other values are normally false. A Boolean is first converted to Number; an object compared with a primitive is first converted to a primitive. Except for an intentional value == null check that matches both nullish values, business code is usually easier to review with strict equality and explicit boundary conversion.
Equality is not one algorithm
Strict equality === doesn’t coerce. Different types are unequal, objects compare by identity, NaN is unequal to itself, and +0 equals -0. It is the default choice for most branches, but it doesn’t provide structural comparison for objects with matching content.
Object.is() uses SameValue semantics: Object.is(NaN, NaN) is true, while Object.is(+0, -0) is false. Array.prototype.includes(), Map keys, and Set members use SameValueZero . It considers NaN equal to itself and the two zeros equal. When you choose a collection or lookup API, these boundary differences directly affect whether a value is found.
Examples
The four examples inspect types, parse external configuration, observe operator coercion, and compare equality semantics. Every output shown came from running the corresponding file locally with Node 24.
Inspecting language types
This probe starts with typeof and adds dedicated checks for null and arrays. It doesn’t try to manufacture one “exact type name” for every built-in object because application code usually needs to validate behavior or fields, not trust a spoofable label.
const samples = [
undefined,
null,
false,
42,
42n,
"42",
Symbol("id"),
[42],
{ value: 42 },
() => 42,
];
for (const value of samples) {
const kind = value === null
? "null"
: Array.isArray(value)
? "array"
: typeof value;
console.log(kind);
}undefined
null
boolean
number
bigint
string
symbol
array
object
functionHere, kind is an application-level classification, not a new ECMAScript type system. It deliberately answers “is this null, an array, or another typeof category?” If a function accepts only a limited structure, continue by checking own properties and their value types.
Parsing configuration at a boundary
Environment variables, URL parameters, and form fields commonly enter a program as strings. This parser accepts only the spellings in its contract, rejects partial numbers, and uses ?? to preserve an empty label.
function parseBoolean(value, field) {
if (value === true || value === "true") return true;
if (value === false || value === "false") return false;
throw new TypeError(`${field} must be true or false`);
}
function parseRetries(value) {
const text = String(value);
if (!/^(0|[1-9]\d*)$/.test(text)) {
throw new TypeError("retries must be a non-negative integer");
}
const retries = Number(text);
if (!Number.isSafeInteger(retries)) {
throw new RangeError("retries is outside the safe integer range");
}
return retries;
}
function parseOptions(raw) {
return {
retries: parseRetries(raw.retries),
verbose: parseBoolean(raw.verbose, "verbose"),
label: raw.label ?? "worker",
};
}
console.log(parseOptions({ retries: "0", verbose: "false", label: "" }));
console.log(parseOptions({ retries: 3, verbose: true }));{ retries: 0, verbose: false, label: '' }
{ retries: 3, verbose: true, label: 'worker' }Number.isSafeInteger() ensures the result is a safe integer , not a Number that has already been rounded. Parsing policy is part of the interface. If the contract permits surrounding whitespace, a leading plus, or localized digits, add those rules to the expression and tests instead of asking a conversion function to guess.
Observing contextual conversion
The same string can take different paths under different operators. JSON.stringify() makes empty strings and spaces within strings visible in the output, so terminal rendering doesn’t hide the result.
const inputs = ["5", "", "false", 0, null];
for (const value of inputs) {
console.log(JSON.stringify({
input: value,
boolean: Boolean(value),
number: Number(value),
defaultWithOr: value || "fallback",
defaultWithNullish: value ?? "fallback",
}));
}
console.log("5" + 2);
console.log("5" - 2);
console.log(1 + 2 + "3");{"input":"5","boolean":true,"number":5,"defaultWithOr":"5","defaultWithNullish":"5"}
{"input":"","boolean":false,"number":0,"defaultWithOr":"fallback","defaultWithNullish":""}
{"input":"false","boolean":true,"number":null,"defaultWithOr":"false","defaultWithNullish":"false"}
{"input":0,"boolean":false,"number":0,"defaultWithOr":"fallback","defaultWithNullish":0}
{"input":null,"boolean":false,"number":0,"defaultWithOr":"fallback","defaultWithNullish":"fallback"}
52
3
33The third line shows null for number. That isn’t because Number("false") returns null: it returns NaN, and JSON serializes a non-finite Number in an array or object as null. When diagnosing conversion, check Number.isNaN() directly instead of treating serialized text as the in-memory value.
Comparing equality and object identity
This example puts strict equality, SameValue, and SameValueZero together. The final two lines show that matching object content doesn’t imply matching object identity.
console.log(NaN === NaN);
console.log(Object.is(NaN, NaN));
console.log([NaN].includes(NaN));
console.log(+0 === -0);
console.log(Object.is(+0, -0));
console.log(new Set([+0, -0]).size);
const first = { id: 7 };
const alias = first;
const copy = { id: 7 };
console.log(first === alias);
console.log(first === copy);false
true
true
true
false
1
true
falseIf the domain considers records with the same id to represent one entity, compare validated id values directly. General deep comparison has to define cycles, prototypes, accessors, Symbol keys, and domain rules. JSON.stringify(a) === JSON.stringify(b) isn’t a reliable substitute.
Pitfalls
Treating typeof as a complete classifier
Fix: exclude null first and use Array.isArray() for arrays. Then validate required fields, property ownership, and value types from the interface rather than inferring a full shape from one coarse label.
Parsing Boolean text with Boolean()
Fix: define the accepted spellings at the boundary, such as exactly "true" and "false", and reject everything else. Use Boolean() only when the input is already a language value and its truth value is what the operation needs.
Letting || overwrite every falsy value
Fix: use ?? when missing means only null or undefined. If an empty string or zero is also missing in this domain, write a named validation condition so the business rule remains visible.
Equating successful conversion with valid input
Fix: validate the complete input syntax before conversion, then check the converted range. For integers, choose Number.isInteger() or Number.isSafeInteger() according to the domain. Avoid global isNaN(), which coerces its argument first.
Forgetting the two meanings of +
Fix: parse both sides into the same numeric type at the calculation boundary. Use a template literal when constructing a message so string intent is visible; don’t borrow addition with an empty string as a conversion tool.
Confusing value equality with object content
Fix: default to === in ordinary branches. Use Number.isNaN() for NaN, follow the documented membership semantics of a collection API, and compare defined keys or implement contract-specific structural comparison for domain objects.
Bindings, mutation, and wrapper objects
A binding and the value currently stored in it are separate parts of the model. Reassigning let current = 1 to current = 2 changes the binding, not the Number value 1. Mutation applies to an object’s properties or internal state instead. This distinction explains why a const object may be mutable while a String held by let remains immutable.
| Operation | Binding changes | Existing value mutates |
|---|---|---|
current = other | Yes | No |
record.count = 2 | No | Yes, if the write succeeds |
text.toUpperCase() | No | No; it returns a new String value |
items.push(value) | No | Yes; the Array object changes |
Object.freeze(record) | No | The object becomes shallowly non-writable through ordinary data properties |
Property access on a primitive can appear object-like because JavaScript temporarily supplies access to wrapper behavior. That is why "hi".toUpperCase() works even though a String value isn’t an object. The temporary mechanism doesn’t turn the original primitive into a mutable record.
Calling String(value), Number(value), or Boolean(value) without new performs conversion. Constructing with new String(), new Number(), or new Boolean() creates a wrapper object instead. Every object is truthy, so new Boolean(false) is truthy and is almost never the value an application intends.
Wrapper objects also fail strict equality against their primitive payloads: new Number(3) !== 3. Accept primitives at ordinary API boundaries unless object identity is explicitly part of the contract. To extract a wrapper’s payload when integrating with legacy code, call value.valueOf() and then validate its primitive type.
Object.freeze() is shallow: it prevents ordinary changes to the frozen object’s own properties, but a nested object may remain mutable. Freezing is an object policy and has no role in making an already immutable primitive “more immutable.”
Functions are callable objects even though typeof reports "function" for convenience. They can own properties and compare by identity just like other objects; the special result doesn’t create a ninth language type.
Object-to-primitive conversion
When syntax needs a primitive but receives an object, it runs the specification’s ToPrimitive abstract operation. If the object provides [Symbol.toPrimitive](hint), the runtime calls it first. It must return a primitive or throw TypeError. The hint can be "number", "string", or "default"; it describes what the syntax prefers rather than requiring a particular primitive type.
Without a custom hook, ordinary conversion tries valueOf() and toString() in an order based on the hint. The string hint normally tries toString() first; other hints normally try valueOf() first, and conversion stops only when a method returns a primitive. Date has special treatment for the default hint, so “adding an empty string to an object always calls valueOf() first” is not a general rule.
Addition applies primitive conversion to both operands. If either converted value is a String, it converts both to String and concatenates; otherwise it converts both to numeric values and adds. A custom Symbol.toPrimitive can therefore affect logging, templates, comparison, and arithmetic. If its answer varies with hidden state, call sites become hard to reason about, so keep the hook simple and give it clear domain meaning.
Symbol string conversion has another easy-to-miss distinction. String(symbol) returns descriptive text, but implicit concatenation and template interpolation throw TypeError for a Symbol value. Diagnostic code that accepts string or Symbol keys should call String(key) explicitly.
Equality boundaries
ECMAScript APIs don’t all call the same equality relation. The table summarizes four common semantics. Every row compares objects by identity and does not inspect their properties automatically.
| Algorithm or entry point | Coerces | NaN equals itself | +0 equals -0 | Common entry point |
|---|---|---|---|---|
| Abstract equality | Yes | No | Yes | == |
| Strict equality | No | No | Yes | ===, indexOf() |
| SameValue | No | Yes | No | Object.is() |
| SameValueZero | No | Yes | Yes | includes(), Map, Set |
Loose equality isn’t random, but it has many branches, and object conversion may invoke user code. value == null is a common narrow idiom that matches only null and undefined. If a team permits it, preserve the intent with a lint exception and a comment. Other cross-type comparisons are usually easier to review after explicit boundary conversion followed by strict equality.
switch matching uses strict equality, so case NaN can never match NaN. Array indexOf(NaN) also can’t find NaN, while includes(NaN) can. Tests for lookup behavior should call the production API rather than substitute another comparison that looks equivalent.
Number, BigInt, and serialization boundaries
Number can represent integers exactly only within a limited range. Number.isSafeInteger() tells you whether an integer still has an unambiguous representation. Consecutive integers outside that range can round to the same Number, so converting with Number(largeText) and then testing safety can reject an unsafe result, but it can’t recover lost digits. Construct a BigInt directly from the original text when large integers are required.
BigInt doesn’t represent fractions and can’t mix with Number in most arithmetic. Relational comparison can compare the two numeric types, but explicit unification makes the precision policy easier to see. Converting an arbitrary Number to BigInt also requires that Number to be an integer; if it was already outside the safe range, the conversion precisely preserves the rounded Number.
Default JSON.stringify() doesn’t support BigInt and throws TypeError. An interface must decide whether to encode large integers as decimal strings, restrict them to safe Numbers, or use another protocol with a defined integer type. After JSON is parsed, inspecting a Number can’t tell you whether the sender had already lost precision, so the contract must choose a representation before transport.
NaN, Infinity, and -Infinity are Number values, but JSON writes them as null when they occur as object properties or array elements. A JSON round trip therefore can’t preserve every JavaScript Number. If non-finite values matter, reject them before serialization or define explicit tagged representations.
Further reading
4 questions · 2 predict-the-output · 1 spot-the-bug