045: Type Conversion, Coercion, and Equality
Outcomes
By the end of this lesson, you should be able to:
- distinguish explicit conversion from implicit coercion;
- predict common string, number, and boolean conversions;
- explain why
===is the normal default for application code; - use
Object.is()when its special equality semantics are useful; - understand the equality behavior used by
Map,Set, and array membership methods; - avoid bugs caused by falsy values,
NaN, and accidental string concatenation.
Prerequisites and Retrieval
You should already be comfortable with primitive values, objects, typeof, null, undefined, BigInt, and Symbol.
Before running these expressions, predict each result. The point is not just to get the answer right; it is to identify which conversions JavaScript is performing:
Number("42");
String(42);
Boolean(0);
Boolean("0");
Mental Model: Conversion Is a Boundary Decision
JavaScript will sometimes convert a value on your behalf. That can be convenient inside a small expression, but it becomes a common source of bugs when data crosses a boundary, such as a form, URL parameters, JSON, storage, or an API payload. Those sources often provide text even when the application concept is numeric or boolean.
The useful rule is to treat conversion as an explicit design decision whenever the expected type matters. Convert and validate at the boundary, then let the rest of the code work with a stable type.
Explicit Type Conversion
Explicit conversion is code that directly asks JavaScript to produce a value of another type. The conversion is visible at the point where it happens, which makes the boundary easier to inspect and test.
To a number
Number("42"); // 42
Number(" 42 "); // 42
Number(""); // 0
Number("42px"); // NaN
Number() attempts to interpret the entire value as a number. Whitespace is accepted, an empty string becomes 0, and a value such as "42px" is not a complete numeric representation, so the result is NaN.
When the input may contain additional text, parseInt() and parseFloat() have different behavior:
parseInt("42px", 10); // 42
parseFloat("12.50kg"); // 12.5
Number("42px"); // NaN
Do not select one by habit. Use parseInt() or parseFloat() when extracting a leading numeric portion is intentional. Use Number() when the entire input must be numeric, and validate the result afterward.
To a string
String(42); // "42"
String(true); // "true"
String(null); // "null"
Template literals also convert interpolated values to strings:
const quantity = 3;
const message = `Quantity: ${quantity}`;
That conversion is useful when building display text, but it does not change the original quantity variable into a string.
To a boolean
Boolean conversion is often used in conditionals, so this is where the terms truthy and falsy matter. The complete list of falsy values is:
false
0
-0
0n
""
null
undefined
NaN
Most other values are truthy, including values that can look empty or false when you are reading them as text:
"0"
"false"
[]
{}
An empty string is falsy, but the string "false" is non-empty and therefore truthy. Empty arrays and objects are objects, not falsy values. This distinction comes up frequently in interviews and production debugging because a value's appearance is not always the same as its boolean behavior.
Implicit Coercion
Implicit coercion is conversion caused by an operator or comparison rather than by an explicit call such as Number() or String(). It is part of JavaScript's rules, not random behavior, but several rules can interact in ways that are difficult to see in a code review.
The + operator is special
The plus operator can mean numeric addition or string concatenation:
1 + 2; // 3
"1" + 2; // "12"
1 + "2"; // "12"
Once string concatenation is involved, the result may not be the numeric calculation the caller intended. This is a particularly common problem with form controls, because an input's .value is a string even when the user typed digits.
A common form bug:
<input id="quantity" value="2">
<input id="price" value="100">
const quantity = document.querySelector("#quantity").value;
const price = document.querySelector("#price").value;
console.log(quantity + price); // "2100"
Both values are strings, so + concatenates them. Fix the boundary instead of relying on a later operation to coerce them:
const quantity = Number(document.querySelector("#quantity").value);
const price = Number(document.querySelector("#price").value);
console.log(quantity * price); // 200
The multiplication now operates on numbers. In real code, conversion should be followed by validation so invalid user input does not silently enter the calculation.
NaN
NaN means "Not-a-Number", although its JavaScript type is still number:
typeof NaN; // "number"
NaN represents an invalid numeric result. It has a deliberately unusual equality rule: it is not equal to itself.
NaN === NaN; // false
Do not use equality to test for it. Use Number.isNaN():
Number.isNaN(NaN); // true
Number.isNaN("not a number"); // false
The global isNaN() function coerces its argument before testing. That makes it easier to misuse when the requirement is specifically to determine whether an existing value is the number NaN.
Loose and Strict Equality
===
Strict equality compares values without applying type coercion:
5 === 5; // true
5 === "5"; // false
Use === and !== as the normal default. When a comparison unexpectedly fails, the difference between 5 and "5" remains visible instead of being hidden by an automatic conversion.
==
Loose equality applies coercion rules before comparing in many cases:
5 == "5"; // true
false == 0; // true
"" == 0; // true
null == undefined; // true
You still need to understand loose equality to read existing code and diagnose behavior. For new application code, avoiding it generally makes control flow easier to reason about.
There is one deliberate pattern you may encounter:
if (value == null) {
// matches null or undefined
}
This condition matches exactly null or undefined, but not other falsy values. If a codebase uses this convention, document it so the intentional exception is clear. Otherwise, prefer explicit checks.
Object.is()
Object.is() mostly behaves like strict equality, with two differences that matter for edge cases:
Object.is(NaN, NaN); // true
Object.is(0, -0); // false
NaN === NaN; // false
0 === -0; // true
Use Object.is() when treating NaN as equal to itself or distinguishing positive zero from negative zero is part of the domain or algorithm. For ordinary application comparisons, === is still the clearer default.
Equality of Objects
Objects compare by identity, not by shape or by the properties they happen to contain:
{ id: 1 } === { id: 1 }; // false
const first = { id: 1 };
const second = first;
first === second; // true
The two object literals have the same property, but they are two different object instances. first and second refer to the same instance, so that comparison is true.
If the application needs to compare object content, define what equality means for that domain rather than assuming === performs a deep comparison:
function sameProduct(a, b) {
return a.id === b.id && a.sku === b.sku;
}
SameValueZero
Not every JavaScript API uses strict equality. Collections such as Set, and membership operations such as Array.prototype.includes(), use an equality algorithm commonly described as SameValueZero.
One practical consequence is that NaN can match itself in these APIs, and duplicate NaN values occupy one Set entry:
[NaN].includes(NaN); // true
const values = new Set([NaN, NaN]);
console.log(values.size); // 1
You do not need to memorize the specification algorithm name immediately. You do need to remember that comparison behavior depends on the API you are using; ===, Object.is(), includes(), Set, and Map are not interchangeable comparison contexts.
Deep Dive: How Objects Become Primitives
An operator may require a primitive value but receive an object instead. In that situation, JavaScript applies the abstract process often called ToPrimitive. You do not need to memorize the specification algorithm line by line, but you should understand that object coercion can invoke methods on the object.
For an ordinary object, valueOf() can provide the value used by numeric-looking operations:
const price = {
valueOf() {
return 250;
},
};
console.log(price + 50); // 300
For string-oriented conversion, toString() may participate:
const product = {
name: "Tea",
toString() {
return this.name;
},
};
console.log(String(product)); // "Tea"
This behavior explains why coercion can execute code that is not obvious from the operator alone. Avoid designing business objects around surprising implicit conversion. An explicit method such as product.getDisplayName() usually communicates intent better.
Symbol.toPrimitive
An object can define its conversion behavior directly with Symbol.toPrimitive:
const money = {
amount: 500,
[Symbol.toPrimitive](hint) {
if (hint === "number") {
return this.amount;
}
return `₹${this.amount}`;
},
};
console.log(Number(money)); // 500
console.log(String(money)); // "₹500"
The hint can be "number", "string", or "default".
This is advanced language machinery. It helps explain the platform and can be appropriate for specialized value objects, but overusing it makes an API harder to read. Explicit conversion or domain methods are generally easier for another developer to follow.
Equality Decision Table
| Situation | Prefer |
|---|---|
| ordinary application equality | === / !== |
need NaN equal to itself and distinguish -0 | Object.is() |
membership with includes(), Set, Map | understand SameValueZero |
intentionally match only null or undefined together | value == null only if project convention permits it |
| compare object contents | write domain-specific comparison |
Relational comparisons also coerce
Relational operators have their own conversion behavior, so equality rules are not the only place where types matter:
"20" < 100; // true
"20" < "100"; // false
The first comparison can become numeric. The second compares two strings lexicographically, character by character, so it does not mean the same thing as comparing their numeric values.
Normalize boundary data before making these comparisons:
const min = Number(formData.get("min"));
const max = Number(formData.get("max"));
if (!Number.isFinite(min) || !Number.isFinite(max)) {
throw new Error("Invalid range");
}
console.log(min < max);
The safest rule throughout this lesson is simple: normalize once, then reason with stable types.
Worked Example: Normalize Checkout Input
Here conversion happens at the boundary of the checkout code. Once this function returns, callers can rely on the validated shapes and numeric types:
function normalizeCheckoutInput(raw) {
const quantity = Number(raw.quantity);
const unitPrice = Number(raw.unitPrice);
const discount = Number(raw.discount ?? 0);
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error("Quantity must be a positive integer");
}
if (!Number.isFinite(unitPrice) || unitPrice < 0) {
throw new Error("Unit price must be a non-negative number");
}
if (!Number.isFinite(discount) || discount < 0) {
throw new Error("Discount must be a non-negative number");
}
return {
quantity,
unitPrice,
discount,
};
}
const input = normalizeCheckoutInput({
quantity: "2",
unitPrice: "350.50",
discount: "",
});
console.log(input);
Conversion occurs once at the boundary. The business logic can then work with predictable types instead of repeatedly guessing what each raw value represents. The example also shows why conversion and validation belong together: conversion produces a candidate value, while validation decides whether that value is acceptable.
Failure Example: Falsy Defaults
A truthiness fallback is not the same as a missing-value fallback:
function normalizeDiscount(value) {
return value || 10;
}
console.log(normalizeDiscount(0)); // 10 — wrong if 0 is valid
|| uses the fallback for every falsy value, including the valid number 0. Use nullish coalescing when only null and undefined should trigger the default:
function normalizeDiscount(value) {
return value ?? 10;
}
console.log(normalizeDiscount(0)); // 0
This distinction also matters for valid false and empty-string values. Choose the operator based on whether you mean “falsy” or “missing.”
Mistakes and Debugging
Common problems include:
- trusting form values to already be numbers;
- using
parseInt()when decimal precision matters; - using
Number()on partial numeric strings without validating the result; - comparing objects by shape with
===; - using
||when0,false, or""are valid values; - checking
NaNwith equality; - reaching for
==because it "fixes" a type mismatch instead of fixing the boundary.
When a value looks suspicious, inspect the value and its type together. The type often identifies the boundary where the bug was introduced:
console.log({
value,
type: typeof value,
});
If the value is NaN, inspect the conversion input next. If it is a string where the code expects a number, trace it back to the form, URL, storage, or API boundary rather than hiding the mismatch with loose equality.
Best Practices
- Convert external input deliberately.
- Validate after conversion.
- Default to
===and!==. - Use
Number.isNaN()andNumber.isFinite()for numeric validation. - Use
??when only missing values should trigger a fallback. - Keep domain comparisons explicit.
- Do not rely on clever coercion as an application design strategy.
Exercises
Core
Predict each result before evaluating it, and explain which operation causes any conversion:
"5" + 1
"5" - 1
Boolean([])
Boolean("")
Number(null)
Number(undefined)
Practice
Write parseQuantity(value) so that it returns a positive integer or throws an error.
Professional Extension
Write normalizeSearchParams(searchParams) that converts:
pageto a positive integer;includeArchivedto a boolean based on"true"or"false";- missing
sortto"newest".
Do not use loose equality.
Complete Solutions
function parseQuantity(value) {
const quantity = Number(value);
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error("Invalid quantity");
}
return quantity;
}
The conversion happens before the integer and positivity checks. That gives the function one predictable numeric value to validate and ensures invalid input fails at the boundary.
function normalizeSearchParams(searchParams) {
const page = Number(searchParams.get("page") ?? 1);
const includeArchived = searchParams.get("includeArchived") === "true";
const sort = searchParams.get("sort") ?? "newest";
if (!Number.isInteger(page) || page <= 0) {
throw new Error("Invalid page");
}
return { page, includeArchived, sort };
}
The boolean conversion is deliberately narrow: only the string "true" produces true; other values produce false. The page is converted and then validated, while the missing sort value uses ?? so the fallback is limited to null or undefined.
Recap
You should now be able to explain:
- the difference between conversion and coercion;
- the difference between strict and loose equality;
- why
NaNneeds special handling; - why object equality means identity;
- why
??differs from||; - why application boundaries are the best place to normalize types.
Official References
- MDN: Type coercion
- MDN: Equality comparisons and sameness
- MDN:
Number - MDN:
Object.is
