LEVEL 2 · LESSON 4 OF 19

Values and operators Foundation

Types in depth

Map every JavaScript type, see why objects are shared while primitives are copied, and learn exactly how equality and type coercion decide what your comparisons return.

  • 50 min to read and try
  • You need: Values, variables and types
  • You build: A fixed shopping-cart bug, a coercion table printed by JavaScript itself, and a safe comparison for data that arrives as text
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Name all seven primitive types and the main built-in object types, and tell them apart at run time
  • Predict when assigning or passing a value copies it and when it shares it, and fix bugs caused by shared objects
  • Explain the difference between an immutable value, a const variable and a frozen object
  • Choose between ===, ==, Object.is and SameValueZero, and explain how each treats NaN, -0, null and objects
  • Predict type coercion with the ToNumber, ToString and ToPrimitive rules, and convert data from outside the program explicitly

Two customers, one cart

A small online shop keeps a template for an empty cart and gives every new customer a copy of it. At least, that is what the developer meant to do:

shared-cart.js
const emptyCart = { items: [], totalKobo: 0 };

const adaCart = emptyCart;
const bolaCart = emptyCart;

adaCart.items.push("Rice 5kg");
adaCart.totalKobo = 850000;

console.log(bolaCart.items, bolaCart.totalKobo);
console.log(emptyCart.items.length);
Output of node shared-cart.js and of the browser terminal
[ 'Rice 5kg' ] 850000
1

Ada added rice to her cart, and it appeared in Bola's cart too, and in the "empty" template. On a real site, every customer would now see everyone else's shopping. items.push(…) adds an item to the end of a list; you will study lists in Arrays, and objects like { items: [], totalKobo: 0 } in Objects and JSON. Here you only need to see the bug.

REASON IT OUT

Before you read on: what does = copy?

With numbers, assignment clearly copies: after let a = 5; let b = a; b = 6;, a is still 5. Yet here, changing adaCart changed bolaCart.

  • How many carts exist in memory after the first three lines? One or three?
  • What do you think adaCart === bolaCart prints?
  • And { items: [] } === { items: [] }, two carts that look exactly the same?
  • Why might a language be designed so that = does not copy objects?
Show the reasoning

One cart. { items: [], totalKobo: 0 } creates one object. adaCart = emptyCart does not copy that object; it makes adaCart point at the same one. All three names are labels on a single cart.

adaCart === bolaCart is true: for objects, === asks "is this the same object?". Two carts written separately are not ===, even with identical contents, because they are two different objects.

Why not copy? Objects can be huge (a list of a million orders) and can contain other objects. Copying on every assignment and every function call would be slow and use a lot of memory, and it would not even be clear how deep to copy. So JavaScript copies only the small thing, the reference, and leaves copying to you, when you ask for it. The rest of this lesson makes that model precise.

Two families of values

Every value in JavaScript belongs to one of two families:

  • Primitive values: the seven simple types you met in Values and types: string, number, bigint, boolean, undefined, null and symbol. A primitive is a single, simple value, and it can never be changed.
  • Objects: everything else. Plain objects, arrays, functions, dates, maps, sets, errors… An object is a container: it holds other values, it can be changed, and it has an identity of its own.

Two of the seven primitives only got a sentence in the first lesson. Here is a little more on each.

BigInt: exact whole numbers of any size

Ordinary numbers are exact only up to Number.MAX_SAFE_INTEGER (about nine quadrillion). A bigint, written with an n at the end, is a whole number that stays exact however large it gets:

bigint.js
console.log(2 ** 64, 2n ** 64n);
console.log(10n / 3n);             // whole numbers only: the fraction is dropped
console.log(typeof 10n, 10n === 10, 10n == 10);

try {
  console.log(10n + 1);
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
console.log(10n + BigInt(1), Number(10n) + 1);
Output of node bigint.js and of the browser terminal
18446744073709552000 18446744073709551616n
3n
bigint false true
TypeError: Cannot mix BigInt and other types, use explicit conversions
11n 11

The ordinary number lost its last digits; the bigint did not. Division drops the fraction. You cannot mix bigints and numbers in arithmetic: JavaScript refuses to guess which precision you wanted, so you convert one side with BigInt() or Number(). In practice you meet bigints for database ids and counters that can exceed the safe range; money in kobo stays comfortably inside it (the safe limit is about ₦90 trillion).

Symbol: a guaranteed-unique value

A symbol is a value that is equal only to itself. Every call to Symbol() makes a new one; the text in the parentheses is only a description for people. Symbols are used as property keys that can never clash with anybody else's keys:

symbol.js
const orderId = Symbol("id");
const otherId = Symbol("id");
console.log(orderId === otherId, orderId.description);

const order = { id: "ORD-1" };
order[orderId] = "internal-7731";   // a hidden key: cannot clash with order.id
console.log(order.id, order[orderId]);

console.log(Symbol.for("app.cache") === Symbol.for("app.cache"));
try {
  console.log("key: " + orderId);
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
Output of node symbol.js and of the browser terminal
false id
ORD-1 internal-7731
true
TypeError: Cannot convert a Symbol value to a string

Symbol.for(name) looks up a shared symbol in a global registry, so the same name gives the same symbol everywhere in the program. A symbol refuses to be quietly turned into text; String(orderId) works because it asks explicitly. You will rarely create symbols yourself, but the language uses them for its own hooks, which Symbols covers.

The map of object types

Most of what you will build is made of objects. JavaScript ships with many kinds, each made for one job. This is the map; each one gets a full lesson later.

TypeMade forCreated withLesson
ObjectA record with named properties: a user, an order{ name: "Ada" }Objects and JSON
ArrayAn ordered list: orders, lines on a receipt[1, 2, 3]Arrays
FunctionCode you can call; also an objectfunction f() {}, () => {}Functions
DateA moment in timenew Date()Dates
MapA lookup table with keys of any typenew Map()Collections
SetA collection of unique valuesnew Set()Sets
WeakMapExtra data attached to objects, forgotten when the object isnew WeakMap()Collections
WeakSetMarking objects ("already processed"), forgotten when the object isnew WeakSet()Collections
RegExpA text pattern to search or validate/^\d+$/Regular expressions
ErrorSomething that went wrong, with a message and a stack tracenew Error("…")Errors

A few of them in action:

object-types.js
const stock = new Map();
stock.set("rice", 12);
stock.set(42, "a number key works too");
console.log(stock.get("rice"), stock.get(42), stock.size);

const tags = new Set(["vip", "new", "vip"]);
console.log(tags.size, tags.has("vip"));

const onlyDigits = /^\d+$/;
console.log(onlyDigits.test("2026"), onlyDigits.test("20x6"));

const problem = new Error("Out of stock");
console.log(problem.name, problem.message);

function applyVat(kobo) {
  return Math.round(kobo * 1.075);
}
applyVat.rate = 7.5;                    // a function is an object: it can hold properties
console.log(applyVat(10000), applyVat.name, applyVat.rate);
Output of node object-types.js and of the browser terminal
12 a number key works too 2
2 true
true false
Error Out of stock
10750 applyVat 7.5

Weak collections

A WeakMap only accepts objects as keys, and it does not keep those objects alive: when nothing else in the program refers to an object, the object and its entry can be thrown away by the garbage collector. That makes it the right tool for data about an object, such as a cache per request, without leaking memory. The price: you cannot list its contents or ask its size, because entries may disappear at any moment.

weakmap.js
const lastSeen = new WeakMap();
const user = { name: "Ada" };
lastSeen.set(user, "09:30");
console.log(lastSeen.get(user), lastSeen.has({ name: "Ada" }));

try {
  lastSeen.set("Ada", "09:30");
} catch (error) {
  console.log(error.name);
}
Output of node weakmap.js and of the browser terminal
09:30 false
TypeError

The second lookup fails because { name: "Ada" } is a different object that just looks the same: maps and sets find object keys by identity, which the next sections explain.

Telling types apart

typeof is enough for primitives, but for objects it only ever says "object" (or "function"). To find out which kind of object you have:

which-object.js
const values = [{}, [], new Date(0), new Map(), /a/, new Error("x"), null, () => 1];

for (const value of values) {
  const tag = Object.prototype.toString.call(value);
  console.log(typeof value, "|", Array.isArray(value), "|", tag);
}
Output of node which-object.js and of the browser terminal
object | false | [object Object]
object | true | [object Array]
object | false | [object Date]
object | false | [object Map]
object | false | [object RegExp]
object | false | [object Error]
object | false | [object Null]
function | false | [object Function]

The for (const value of values) loop runs its line once for each value in the list; Loops covers it. Use Array.isArray(x) to check for arrays, x instanceof Date (read: "was x made by Date?") for the other built-in types, and x === null for null, since typeof null is "object". Object.prototype.toString.call(x) is the most precise tag, mostly used in libraries. And every object type is a special kind of object, so an array is also an Object:

instanceof.js
console.log(new Date(0) instanceof Date, [] instanceof Array, [] instanceof Object);
Output of node instanceof.js and of the browser terminal
true true true

Copied or shared: primitives and references

Here is the model that explains the shared cart. A variable is a box. For a primitive, the box holds the value itself. For an object, the object lives somewhere else in memory, and the box holds a reference to it: an arrow pointing at it.

let a = 5;            a        [ 5 ]
let b = a;            b        [ 5 ]              two boxes, two separate 5s

const cart1 = {…};    cart1    [ ●──────┐ ]
const cart2 = cart1;  cart2    [ ●──────┤ ]
                                        ▼
                               { items: [], totalKobo: 0 }   one object

Assignment always copies what is in the box. For a primitive that is the value, so the copy is independent. For an object it is the arrow, so both boxes now point at the same object. Changing the object through either name changes the one object that both see.

Passing values to functions

A function's parameters are new boxes, filled by assignment from the arguments. So the same rule applies. This is sometimes called pass by sharing:

pass-by-sharing.js
function applyDiscount(order, percent) {
  order.totalKobo = order.totalKobo - (order.totalKobo * percent) / 100;
  percent = 0;                        // changes only this function's box
}

function replaceOrder(order) {
  order = { totalKobo: 0 };           // points this box at a new object
}

const order = { totalKobo: 1000000 };
let discount = 10;

applyDiscount(order, discount);
console.log(order.totalKobo, discount);

replaceOrder(order);
console.log(order.totalKobo);
Output of node pass-by-sharing.js and of the browser terminal
900000 10
900000
  • applyDiscount followed the arrow and changed the caller's order. That is visible outside.
  • Setting percent = 0 changed the function's own box; the caller's discount is still 10.
  • replaceOrder pointed its own box at a new object. The caller's box still points at the old order, which is unchanged.

Changing an object you were given, as applyDiscount does, is called a side effect, and it is a common source of bugs in backends: a function quietly edits an object that other code is still using. Many teams prefer functions that return a new object instead, a habit you will build in Functions.

Fixing the shared cart

The fix is to create a new object for every customer. The simplest way is a function that builds one each time it is called:

cart-fixed.js
function createCart() {
  return { items: [], totalKobo: 0 };
}

const adaCart = createCart();
const bolaCart = createCart();

adaCart.items.push("Rice 5kg");
adaCart.totalKobo = 850000;

console.log(adaCart.items, bolaCart.items, adaCart === bolaCart);
Output of node cart-fixed.js and of the browser terminal
[ 'Rice 5kg' ] [] false

Each call runs { items: [], totalKobo: 0 } again, and each run makes a brand-new object with a brand-new list inside. A function that creates objects like this is called a factory. Copying an existing object is the other option; spread ({ ...cart }) makes a shallow copy and structuredClone(cart) a deep one, as Objects and JSON shows. The factory is simpler when you are starting from a fixed template.

Mutability: values, variables and frozen objects

"Can this change?" has three different answers in JavaScript, depending on what "this" is:

WhatCan it change?Example
A primitive valueNever. Operations make new values."ada".toUpperCase() returns a new string
A const variableThe box cannot be pointed elsewhere, but the object it points at can change.const cart = …; cart.items.push(x) works
A frozen objectIts own properties cannot change (one level deep).Object.freeze(settings)
mutability.js
const name = "ada";
const loud = name.toUpperCase();
console.log(name, loud);

try {
  name[0] = "A";               // strings cannot be changed in place
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}

const cart = { items: [] };
cart.items.push("Garri");     // allowed: the object changes, the variable does not
console.log(cart.items);

try {
  cart = { items: [] };       // not allowed: a const box cannot be pointed elsewhere
} catch (error) {
  console.log(`${error.name}: ${error.message}`);
}
Output of node mutability.js and of the browser terminal
ada ADA
TypeError: Cannot assign to read only property '0' of string 'ada'
[ 'Garri' ]
TypeError: Assignment to constant variable.

An object whose contents can change is mutable; a value that can never change is immutable. All primitives are immutable: name[0] = "A" is refused (in older, non-strict code it was silently ignored, which was worse). const is about the variable, not the value. To protect the object itself, freeze it:

freeze.js
const settings = Object.freeze({ currency: "NGN", vatPercent: 7.5, limits: { daily: 100000 } });

try {
  settings.vatPercent = 0;
} catch (error) {
  console.log(error.name);
}
settings.limits.daily = 999999999;     // freeze is shallow: the inner object is not frozen
console.log(settings.vatPercent, settings.limits.daily, Object.isFrozen(settings));
Output of node freeze.js and of the browser terminal
TypeError
7.5 999999999 true

Freezing stops changes to the object's own properties, but only one level deep: limits is a separate object and is still mutable. Objects in depth shows how to freeze deeply. ZudoJS freezes its configuration objects for exactly this reason: settings that nobody can change by accident are settings you can trust.

Methods that change and methods that copy

Some built-in methods change the object you call them on; others return a new one. Knowing which is which prevents the shared-object bug in its sneakier forms:

mutating-methods.js
const prices = [1500, 200, 850];

const sortedCopy = prices.toSorted((a, b) => a - b);
console.log(prices, sortedCopy);

prices.sort((a, b) => a - b);        // sorts the list itself
console.log(prices);
Output of node mutating-methods.js and of the browser terminal
[ 1500, 200, 850 ] [ 200, 850, 1500 ]
[ 200, 850, 1500 ]

sort, reverse, push, pop and splice change the list. toSorted, toReversed, slice, map and filter return a new one. String methods never change the string, because strings are immutable.

Identity and equality

"Are these equal?" can mean three different questions:

  1. Same value? For primitives: are these the same number, the same text?
  2. Same identity? For objects: are these two references to the very same object?
  3. Same contents? Do these two objects have the same properties with the same values?

=== answers question 1 for primitives and question 2 for objects. Nothing built into the language answers question 3 directly:

identity.js
const a = { sku: "RICE-5", qty: 2 };
const b = { sku: "RICE-5", qty: 2 };
const c = a;

console.log(a === b, a === c);
console.log([] === [], "RICE-5" === "RICE-" + "5");

console.log(a.sku === b.sku && a.qty === b.qty);
console.log(JSON.stringify(a) === JSON.stringify(b));
console.log(JSON.stringify({ sku: "RICE-5", qty: 2 }) === JSON.stringify({ qty: 2, sku: "RICE-5" }));
Output of node identity.js and of the browser terminal
false true
false true
true
true
false

To compare contents, compare the fields that matter, as the third line does. Comparing JSON.stringify text works for simple data but depends on the order the properties were written in, as the last line shows. Test runners have a proper deep comparison: in Vitest, toBe checks identity and toEqual checks contents (Testing basics).

Four ways to compare

JavaScript actually has four equality algorithms. You write two of them yourself; the other two are used for you by built-in methods:

  • ===, strict equality: same type and same value (or same object). Two exceptions: NaN === NaN is false, and 0 === -0 is true.
  • ==, loose equality: converts the two sides first, following rules explained in the next section.
  • Object.is(a, b), same value: like ===, but without the two exceptions: NaN equals NaN, and 0 and -0 are different.
  • SameValueZero: like Object.is, except that 0 and -0 are equal. You never call it by name; includes, Map and Set use it.

Negative zero, -0, is a real value in JavaScript: numbers carry a sign even when they are zero. It appears when a negative number is rounded to zero or multiplied by zero, and it can matter when the sign means something, such as the direction of a price change.

four-equalities.js
console.log(NaN === NaN, Object.is(NaN, NaN));
console.log(0 === -0, Object.is(0, -0));
console.log(Math.round(-0.4), -5 * 0, String(-0));

console.log([NaN].includes(NaN), [NaN].indexOf(NaN));
console.log(new Set([NaN, NaN, 0, -0]).size);

const ids = new Map([[1, "number one"], ["1", "text one"]]);
console.log(ids.get(1), "|", ids.get("1"));
Output of node four-equalities.js and of the browser terminal
false true
true false
-0 -0 0
true -1
2
number one | text one
Comparison=====Object.isSameValueZero (includes, Map, Set)
NaN and NaNfalsefalsetruetrue
0 and -0truetruefalsetrue
1 and "1"truefalsefalsefalse
null and undefinedtruefalsefalsefalse
two different {}falsefalsefalsefalse

Notice indexOf: it uses ===, so it can never find NaN, while includes can. And the Map keeps 1 and "1" apart. That is exactly the bug waiting for you when an id arrives from a web address as text and your table uses numbers.

Type coercion, precisely

In Values and types you saw that JavaScript converts values on its own: "5" - 1 is 4. This is implicit coercion. It is not random. Every operator follows the same few conversion rules, and once you know them you can predict any result.

To a number

Arithmetic (except + with text), comparisons with < and >, and Number(x) all use the same conversion to a number. Let JavaScript print its own table:

to-number.js
const inputs = [undefined, null, true, false, "", "  12  ", "12px", "0x1A", "1e3", [], [5], [1, 2], {}];
const labels = ["undefined", "null", "true", "false", '""', '"  12  "', '"12px"', '"0x1A"', '"1e3"', "[]", "[5]", "[1, 2]", "{}"];

for (let i = 0; i < inputs.length; i++) {
  console.log(labels[i].padEnd(10), "->", Number(inputs[i]));
}
Output of node to-number.js and of the browser terminal
undefined  -> NaN
null       -> 0
true       -> 1
false      -> 0
""         -> 0
"  12  "   -> 12
"12px"     -> NaN
"0x1A"     -> 26
"1e3"      -> 1000
[]         -> 0
[5]        -> 5
[1, 2]     -> NaN
{}         -> NaN

The rules behind the table: undefined becomes NaN but null becomes 0; booleans become 1 and 0; text is read as a number after trimming spaces, with empty text as 0, hexadecimal (0x) and exponent (1e3) forms allowed, and anything else NaN. Objects are first turned into a primitive (see below): an array becomes its text, so [] is "" (0), [5] is "5" and [1, 2] is "1,2" (NaN). padEnd(10) only pads the labels with spaces so the arrows line up.

To a string

to-string.js
console.log(String(null), String(undefined), String(true), String(-0));
console.log(`[${String([1, 2, 3])}] [${String([])}] [${String([null, undefined])}]`);
console.log(String({}), String(1e21), String(0.000001), String(0.0000001));
Output of node to-string.js and of the browser terminal
null undefined true 0
[1,2,3] [] [,]
[object Object] 1e+21 0.000001 1e-7

Arrays join their items with commas (and null and undefined items become empty), plain objects become the unhelpful "[object Object]", -0 loses its sign, and very large or very small numbers switch to exponent notation. When "[object Object]" shows up in a web page or a log, some code turned an object into text by accident.

Objects to primitives: valueOf and toString

When an operator needs a primitive and gets an object, JavaScript asks the object for one. It calls the object's valueOf() method, and if that does not return a primitive, its toString() method (when text is preferred, as in a template literal, it tries toString() first). You can define both on your own objects, and then the operators use them:

to-primitive.js
const price = {
  kobo: 150000,
  valueOf() {
    return this.kobo;
  },
  toString() {
    return "₦1,500.00";
  },
};

console.log(price + 500, price * 2, price > 100000);
console.log(`${price}`, String(price));
Output of node to-primitive.js and of the browser terminal
150500 300000 true
₦1,500.00 ₦1,500.00

Arithmetic used valueOf (the number of kobo); the template literal and String() used toString. this.kobo means "the kobo of the object this method belongs to"; this explains it. Be careful with this power: an object that behaves like a number in some places and like text in others is clever, and clever code is hard to read. Explicit methods such as price.format() are usually clearer.

The + operator

+ is the one operator with two jobs, so it has its own rule: convert both sides to primitives; if either is a string, join them as text; otherwise, add them as numbers.

plus.js
console.log(1 + null, 1 + undefined, true + true);
console.log("3" + 4, 3 + 4 + "5", "3" + 4 + 5);
console.log(`[${[] + []}]`, [] + {}, [1, 2] + [3]);
console.log("3" * "4", "10" / "4", "7" - - "2");
Output of node plus.js and of the browser terminal
1 NaN 2
34 75 345
[] [object Object] 1,23
12 2.5 9

3 + 4 + "5" is "75" because + works left to right: first 3 + 4 is 7, then 7 + "5" joins. [] + [] is empty text, since both arrays turn into "". The other arithmetic operators have only one job, so *, / and - always convert to numbers: "7" - - "2" is 7 minus negative 2.

Comparisons with < and >

If both sides are strings, they are compared as text, character by character. Otherwise both are converted to numbers. And == has rules of its own, which produce one of the most famous oddities in the language:

relational.js
console.log("10" < "9", "10" < 9, "abc" < 5, "abc" > 5);
console.log(null >= 0, null > 0, null == 0);
console.log(undefined >= 0, undefined == 0);
Output of node relational.js and of the browser terminal
true false false false
true false false
false false

"abc" becomes NaN, and every comparison with NaN is false, in both directions. null >= 0 is true (null becomes 0 for >=) while null == 0 is false, because == does not convert null to a number at all. That is the reason for the rule of thumb: never rely on how null and undefined compare; check for them explicitly first.

The rules of ==

Loose equality follows these steps, in order:

  1. Same type on both sides: compare exactly like ===.
  2. null and undefined are equal to each other, and to nothing else.
  3. A number and a string: convert the string to a number.
  4. A boolean on either side: convert the boolean to a number (1 or 0) and start again.
  5. An object and a primitive: convert the object to a primitive and start again.
  6. A bigint and a number (or string) compare by their mathematical value.

Here is the complete == table for ten common values, printed by JavaScript. == marks a pair that is loosely equal:

loose-table.js
const values = [0, "", "0", false, null, undefined, NaN, [], "1", 1];
const names = ["0", '""', '"0"', "false", "null", "undef", "NaN", "[]", '"1"', "1"];

let header = "      ";
for (const name of names) header = header + name.padEnd(6);
console.log(header);

for (let row = 0; row < values.length; row++) {
  let line = names[row].padEnd(6);
  for (let col = 0; col < values.length; col++) {
    line = line + (values[row] == values[col] ? "==" : ".").padEnd(6);
  }
  console.log(line);
}
Output of node loose-table.js and of the browser terminal
      0     ""    "0"   false null  undef NaN   []    "1"   1
0     ==    ==    ==    ==    .     .     .     ==    .     .
""    ==    ==    .     ==    .     .     .     ==    .     .
"0"   ==    .     ==    ==    .     .     .     .     .     .
false ==    ==    ==    ==    .     .     .     ==    .     .
null  .     .     .     .     ==    ==    .     .     .     .
undef .     .     .     .     ==    ==    .     .     .     .
NaN   .     .     .     .     .     .     .     .     .     .
[]    ==    ==    .     ==    .     .     .     ==    .     .
"1"   .     .     .     .     .     .     .     .     ==    ==
1     .     .     .     .     .     .     .     .     ==    ==

You do not need to follow the two loops that print it (they come in Loops); read the table. It is full of results that break ordinary logic. 0 == "" and 0 == "0", but "" == "0" is false, so == is not even consistent with itself. "0" == false, yet Boolean("0") is true, so a value can be "equal to false" and truthy at once. NaN equals nothing, not even itself. The === version of this table is just the diagonal, minus NaN.

Use ===

This table is why the course, ZudoJS, and almost every style guide use === and !== everywhere. Linters can enforce it (ESLint's eqeqeq rule). The one widely accepted exception is value == null, which is true for exactly null and undefined, as the table shows, and which Operators uses.

Real application: data from outside the program

Coercion bugs rarely come from values you typed. They come from values that arrived: form fields, web addresses, CSV files, environment variables. All of those are text, whatever they look like.

REASON IT OUT

Before you code: an id from a web address

A request arrives for /orders/42. The server reads the id from the address as the string "42". Orders are stored in a Map keyed by numeric ids.

  • What does orders.get("42") return, and why?
  • Would order.id == id "fix" it? What else would that let through?
  • Which inputs must be rejected: "42abc", "", "4.2", "-1", " 42 "?
  • Where in the program should the conversion happen?
Show the reasoning

orders.get("42") is undefined. Maps compare keys with SameValueZero, which never converts types: "42" and 42 are different keys. The order looks missing although it exists.

== hides the problem instead of solving it. 42 == "42" is true, but so is 0 == "", so an empty id would match order 0. And == does not help Map lookups at all.

Rejecting input: Number("42abc") is NaN, but Number("") is 0 and Number(" 42 ") is 42 (spaces are trimmed), so Number() alone is too forgiving. An id is a positive whole number, so the rule is: the text must be only digits, and the number must be a safe integer above 0. "4.2" and "-1" fail that rule.

Convert at the edge. Convert and validate once, where the text enters the program, then pass only real numbers inside it. Code deep inside the program should never have to wonder whether an id is text.

parse-id.js
const orders = new Map([
  [42, { id: 42, totalKobo: 1350000 }],
  [7, { id: 7, totalKobo: 450000 }],
]);

function parseId(text) {
  if (!/^\d+$/.test(text)) return null;        // only the digits 0-9, at least one
  const id = Number(text);
  if (!Number.isSafeInteger(id) || id === 0) return null;
  return id;
}

console.log(orders.get("42"), orders.get(42).totalKobo);

for (const input of ["42", "42abc", "", "4.2", "-1", " 42 ", "0", "007"]) {
  const id = parseId(input);
  const found = id === null ? "rejected" : orders.get(id) ? "found" : "no such order";
  console.log(JSON.stringify(input).padEnd(8), id, found);
}
Output of node parse-id.js and of the browser terminal
undefined 1350000
"42"     42 found
"42abc"  null rejected
""       null rejected
"4.2"    null rejected
"-1"     null rejected
" 42 "   null rejected
"0"      null rejected
"007"    7 found

/^\d+$/ is a regular expression meaning "one or more digits and nothing else"; Regular expressions explains the syntax. Notice the decision in the last line: "007" is accepted as order 7. That may or may not be what you want, and a test now documents it. Later in the course, @zudojs/validation does this kind of parsing at the edge of every request, so the code behind it only ever sees checked, correctly typed data.

TIP

The same applies to process.env in Node.js: every environment variable is a string, so process.env.PORT is "3000", never 3000. "3000" + 1 is "30001".

Testing with types in mind

Everything in this lesson turns into test cases. When a function takes outside data, its edge-case table should include:

  • the right value as text ("42") and as a number (42);
  • empty text, text with spaces, null and undefined;
  • NaN, 0 and, where the sign matters, -0;
  • for functions that receive objects, a check that the caller's object is unchanged afterwards (or deliberately changed);
  • for functions that return objects, a check of the contents, not the identity, and a check that two calls return two separate objects when they should.
type-tests.js
function check(label, actual, expected) {
  console.log(Object.is(actual, expected) ? `PASS ${label}` : `FAIL ${label}: got ${actual}, expected ${expected}`);
}

function createCart() {
  return { items: [], totalKobo: 0 };
}

function withItem(cart, item, priceKobo) {
  return { items: [...cart.items, item], totalKobo: cart.totalKobo + priceKobo };
}

const empty = createCart();
const one = withItem(empty, "Rice 5kg", 850000);

check("new cart each call", createCart() === createCart(), false);
check("original untouched", empty.items.length, 0);
check("item added to copy", one.items.length, 1);
check("total", one.totalKobo, 850000);
check("NaN is detected", Number("12px"), NaN);
check("-0 is detected", Math.round(-0.2), -0);
Output of node type-tests.js and of the browser terminal
PASS new cart each call
PASS original untouched
PASS item added to copy
PASS total
PASS NaN is detected
PASS -0 is detected

This check uses Object.is instead of ===, so that a test expecting NaN can pass, and a test can tell 0 from -0. withItem returns a new cart instead of changing the one it was given: [...cart.items, item] builds a new list from the old items plus one more (spread, covered in Modern JavaScript).

Practice

TRY IT YOURSELF

Predict the coercions

Write down what each line prints before you run it. Then use the rules from this lesson to explain every surprise.

console.log("5" * "2", "5" + 2, 5 + +"2");
console.log([] == false, [0] == false, [1] == true);
console.log(null + 1, undefined + 1, "" - 1);
console.log(Object.is(-0, 0), [-0].includes(0));
Show a solution
predict-coercion.js
console.log("5" * "2", "5" + 2, 5 + +"2");
console.log([] == false, [0] == false, [1] == true);
console.log(null + 1, undefined + 1, "" - 1);
console.log(Object.is(-0, 0), [-0].includes(0));
Output of node predict-coercion.js and of the browser terminal
10 52 7
true true true
1 NaN -1
false true

* always converts to numbers; + with a string joins; unary +"2" converts to 2 first. For == with a boolean, the boolean becomes a number, then the array becomes text: [] is "" which is 0, [0] is "0" which is 0, [1] is "1" which is 1. null is 0 in arithmetic but undefined is NaN. includes uses SameValueZero, where -0 and 0 are equal.

TRY IT YOURSELF

Stop sharing the default settings

Every new shop account should start with default notification settings. After one shop turns off SMS, every other shop has it turned off too. Explain why, then fix it so each shop gets its own settings.

const defaultSettings = { sms: true, email: true };

function createShop(name) {
  return { name, settings: defaultSettings };
}

const a = createShop("Mama Titi");
const b = createShop("Chinedu Electronics");
a.settings.sms = false;
console.log(b.settings.sms);
Show a solution

createShop creates a new shop object each time, but its settings property holds a reference to the one defaultSettings object. All shops share it. Create a new settings object per shop, and freeze the defaults so a future mistake fails loudly instead of changing every shop:

shop-settings.js
const defaultSettings = Object.freeze({ sms: true, email: true });

function createShop(name) {
  return { name, settings: { ...defaultSettings } };
}

const a = createShop("Mama Titi");
const b = createShop("Chinedu Electronics");
a.settings.sms = false;
console.log(a.settings.sms, b.settings.sms, a.settings === b.settings);
Output of node shop-settings.js and of the browser terminal
false true false

{ ...defaultSettings } copies the properties into a new object. A shallow copy is enough here because the settings contain only primitives. { name, … } is short for { name: name, … }.

TRY IT YOURSELF

Read a quantity from a form

A form sends the quantity of an item as text. Write parseQuantity(text) that returns a whole number from 1 to 99, or null for anything else. Reason first: what do Number(""), Number(" 3 ") and Number("2.5") give, and which of them should be accepted?

Show a solution

Number("") is 0 and Number(" 3 ") is 3, so Number() alone would accept empty text as 0 and quietly accept spaces. Decide: trim spaces (people type them by accident), refuse empty text, then require a whole number in range.

parse-quantity.js
function parseQuantity(text) {
  const trimmed = text.trim();
  if (trimmed === "") return null;
  const n = Number(trimmed);
  if (!Number.isInteger(n) || n < 1 || n > 99) return null;
  return n;
}

for (const input of ["3", " 3 ", "", "0", "2.5", "100", "3 bags", "1e1"]) {
  console.log(JSON.stringify(input), parseQuantity(input));
}
Output of node parse-quantity.js and of the browser terminal
"3" 3
" 3 " 3
"" null
"0" null
"2.5" null
"100" null
"3 bags" null
"1e1" 10

Look at the last line: "1e1" is exponent notation for 10, and Number() accepts it. Is that acceptable for a quantity field? If not, add a digits-only check like parseId above. Every decision like this belongs in a test.

Recap

  • Seven primitives (string, number, bigint, boolean, undefined, null, symbol) are single, immutable values. Everything else is an object: Object, Array, Function, Date, Map, Set, WeakMap, WeakSet, RegExp, Error and more.
  • Use typeof for primitives, Array.isArray and instanceof for objects, and === null for null.
  • Assignment and argument passing copy what is in the variable: the value for primitives, a reference for objects. Two names can point at one object; create new objects (a factory, a copy) when you need separate ones.
  • const fixes the variable, not the object. Object.freeze fixes an object's own properties, one level deep.
  • === compares values or identity; Object.is also handles NaN and -0; includes, Map and Set use SameValueZero. Nothing built in compares object contents.
  • Coercion follows fixed rules: ToNumber, ToString, and ToPrimitive via valueOf/toString. + joins if either side is text. == has its own steps that break ordinary logic, so use ===.
  • Data from outside is text. Convert and validate it once, at the edge of the program.

Next, Operators puts these values to work: arithmetic, comparison, logic, and safe defaults for missing values.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.