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.
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:
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);
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 === bolaCartprints? - 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:
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);
node bigint.js and of the browser terminal18446744073709552000 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:
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}`);
}
node symbol.js and of the browser terminalfalse 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.
| Type | Made for | Created with | Lesson |
|---|---|---|---|
| Object | A record with named properties: a user, an order | { name: "Ada" } | Objects and JSON |
| Array | An ordered list: orders, lines on a receipt | [1, 2, 3] | Arrays |
| Function | Code you can call; also an object | function f() {}, () => {} | Functions |
| Date | A moment in time | new Date() | Dates |
| Map | A lookup table with keys of any type | new Map() | Collections |
| Set | A collection of unique values | new Set() | Sets |
| WeakMap | Extra data attached to objects, forgotten when the object is | new WeakMap() | Collections |
| WeakSet | Marking objects ("already processed"), forgotten when the object is | new WeakSet() | Collections |
| RegExp | A text pattern to search or validate | /^\d+$/ | Regular expressions |
| Error | Something that went wrong, with a message and a stack trace | new Error("…") | Errors |
A few of them in action:
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);
node object-types.js and of the browser terminal12 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.
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);
}
node weakmap.js and of the browser terminal09: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:
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);
}
node which-object.js and of the browser terminalobject | 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:
console.log(new Date(0) instanceof Date, [] instanceof Array, [] instanceof Object);
node instanceof.js and of the browser terminaltrue 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:
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);
node pass-by-sharing.js and of the browser terminal900000 10 900000
applyDiscountfollowed the arrow and changed the caller's order. That is visible outside.- Setting
percent = 0changed the function's own box; the caller'sdiscountis still 10. replaceOrderpointed 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:
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);
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:
| What | Can it change? | Example |
|---|---|---|
| A primitive value | Never. Operations make new values. | "ada".toUpperCase() returns a new string |
A const variable | The box cannot be pointed elsewhere, but the object it points at can change. | const cart = …; cart.items.push(x) works |
| A frozen object | Its own properties cannot change (one level deep). | Object.freeze(settings) |
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}`);
}
node mutability.js and of the browser terminalada 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:
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));
node freeze.js and of the browser terminalTypeError 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:
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);
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:
- Same value? For primitives: are these the same number, the same text?
- Same identity? For objects: are these two references to the very same object?
- 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:
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" }));
node identity.js and of the browser terminalfalse 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 === NaNis false, and0 === -0is 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:NaNequalsNaN, and0and-0are different.- SameValueZero: like
Object.is, except that0and-0are equal. You never call it by name;includes,MapandSetuse 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.
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"));
node four-equalities.js and of the browser terminalfalse true true false -0 -0 0 true -1 2 number one | text one
| Comparison | == | === | Object.is | SameValueZero (includes, Map, Set) |
|---|---|---|---|---|
NaN and NaN | false | false | true | true |
0 and -0 | true | true | false | true |
1 and "1" | true | false | false | false |
null and undefined | true | false | false | false |
two different {} | false | false | false | false |
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:
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]));
}
node to-number.js and of the browser terminalundefined -> NaN
null -> 0
true -> 1
false -> 0
"" -> 0
" 12 " -> 12
"12px" -> NaN
"0x1A" -> 26
"1e3" -> 1000
[] -> 0
[5] -> 5
[1, 2] -> NaN
{} -> NaNThe 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
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));
node to-string.js and of the browser terminalnull 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:
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));
node to-primitive.js and of the browser terminal150500 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.
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");
node plus.js and of the browser terminal1 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:
console.log("10" < "9", "10" < 9, "abc" < 5, "abc" > 5);
console.log(null >= 0, null > 0, null == 0);
console.log(undefined >= 0, undefined == 0);
node relational.js and of the browser terminaltrue 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:
- Same type on both sides: compare exactly like
===. nullandundefinedare equal to each other, and to nothing else.- A number and a string: convert the string to a number.
- A boolean on either side: convert the boolean to a number (1 or 0) and start again.
- An object and a primitive: convert the object to a primitive and start again.
- 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:
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);
}
node loose-table.js and of the browser terminal0 "" "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.
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);
}
node parse-id.js and of the browser terminalundefined 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 toprocess.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,
nullandundefined; NaN,0and, 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.
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);
node type-tests.js and of the browser terminalPASS 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
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));
node predict-coercion.js and of the browser terminal10 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:
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);
node shop-settings.js and of the browser terminalfalse 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.
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));
}
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
typeoffor primitives,Array.isArrayandinstanceoffor objects, and=== nullfor 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.
constfixes the variable, not the object.Object.freezefixes an object's own properties, one level deep.===compares values or identity;Object.isalso handles NaN and -0;includes,MapandSetuse 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.