Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

jQuery 4 (legacy code and migration to DOM APIs)  Module 2 – Selecting, reading and changing the page

$.each, $.map, $.extend: utilities and native code

What $.each, $.map, $.grep, $.extend, $.inArray and $.param do, the native code that gives the same results, where it differs, and what jQuery 4 removed.

  • Intermediate
  • 30 minutes
  • Examples run with Node.js 24.21.0, jQuery 4.0.0, quickjs 0.32.0 and PHP 8.5.11
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Predict what $.each, $.map, $.grep and $.inArray return, including the order of each callback's arguments
  • Replace each utility with native code that gives the same results, and name the cases where the native call differs
  • Choose between $.extend, Object.assign, spread, structuredClone and a merge you write, for copies and for defaults
  • Explain why URLSearchParams changes what a PHP server receives from $.param, and keep the bracket names it expects
  • Replace the utilities jQuery 4 removed, such as $.trim and $.isArray, without changing what the code returns

Before you start

What you’ll learn · Before you start

What you will learn

  • Predict what $.each, $.map, $.grep and $.inArray return, including the order of each callback's arguments
  • Replace each utility with native code that gives the same results, and name the cases where the native call differs
  • Choose between $.extend, Object.assign, spread, structuredClone and a merge you write, for copies and for defaults
  • Explain why URLSearchParams changes what a PHP server receives from $.param, and keep the bracket names it expects
  • Replace the utilities jQuery 4 removed, such as $.trim and $.isArray, without changing what the code returns

Before you start

On this page

A checkout script tests if ($.inArray('card', methods)), and the card option disappears whenever card comes first in the list: $.inArray returns its position, 0, which is falsy. jQuery’s utility functions look like the array methods every browser now has, but each one has its own rules: the order of a callback’s arguments, what stops a loop, and what is dropped, shared or copied.

The order-report pages of this lesson run each utility in jsdom on jQuery 4.0.0 and 3.7.1, next to the native code that replaces it, and a PHP program shows what a server receives. You will predict what each utility returns, write native code with the same results, choose a copy or a merge for each $.extend call, keep the form bodies a PHP server expects, and replace the eleven utilities that jQuery 4 removed.

$.each passes the index first and stops when the callback returns false

$.each(collection, callback) calls the callback with the index and then the value for an array or an array-like object: An object with a length and items numbered 0 to length - 1, such as a function's arguments, a NodeList or a jQuery object, that is not an array. $.each() and $.map() go through it by index., or the key and then the value for any other object. It stops as soon as the callback returns false; any other return value moves on to the next item, like continue (jQuery.each()). forEach() passes the value first and cannot stop at all. The order report runs both, and for...of with break, on the same four items:

The order report and each.js

orders/each.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Order report</title>
  </head>
  <body>
    <ul id="items">
      <li>kettle</li>
      <li>lamp</li>
    </ul>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="each.js"></script>
  </body>
</html>

orders/each.js

// each.js: $.each against the native loops, on an order's items, an array with a hole and a plain object.
const items = ['kettle', 'lamp', 'clock', 'fan'];

const viaEach = [];
$.each(items, function (index, value) {
  viaEach.push(`${index}:${value}`);
  if (value === 'lamp') return false; // false stops $.each
});
console.log(`$.each, return false at lamp: ${viaEach.join(' ')}`);

const viaForEach = [];
items.forEach((value, index) => {
  viaForEach.push(`${index}:${value}`);
  if (value === 'lamp') return false; // forEach ignores what the callback returns
});
console.log(`forEach, return false at lamp: ${viaForEach.join(' ')}`);

const viaForOf = [];
for (const [index, value] of items.entries()) {
  viaForOf.push(`${index}:${value}`);
  if (value === 'lamp') break;
}
console.log(`for...of, break at lamp: ${viaForOf.join(' ')}`);

// this is the value, wrapped in an object outside strict mode.
$.each([5], function (index, value) {
  console.log(`this in a $.each callback: typeof ${typeof this}, this == value ${this == value}, this === value ${this === value}`);
});

// An array with a hole: the middle position was never set.
const sparse = [1, , 3];
const a = [];
$.each(sparse, (index, value) => a.push(`${index}=${value}`));
const b = [];
sparse.forEach((value, index) => b.push(`${index}=${value}`));
const c = [];
for (const value of sparse) c.push(String(value));
console.log(`a hole: $.each ${a.join(' ')} | forEach ${b.join(' ')} | for...of ${c.join(' ')}`);

// A plain object: $.each passes key and value; Object.entries() gives the same pairs to a native loop.
const stock = { kettle: 12, lamp: 0 };
const d = [];
$.each(stock, (key, value) => d.push(`${key}=${value}`));
const e = [];
for (const [key, value] of Object.entries(stock)) e.push(`${key}=${value}`);
console.log(`an object: $.each ${d.join(' ')} | Object.entries ${e.join(' ')}`);
What each.js logged JavaScript · each.mjs
// Prints what orders/each.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('orders/each.html');

Output

$.each, return false at lamp: 0:kettle 1:lamp
forEach, return false at lamp: 0:kettle 1:lamp 2:clock 3:fan
for...of, break at lamp: 0:kettle 1:lamp
this in a $.each callback: typeof object, this == value true, this === value false
a hole: $.each 0=1 1=undefined 2=3 | forEach 0=1 2=3 | for...of 1 undefined 3
an object: $.each kettle=12 lamp=0 | Object.entries kettle=12 lamp=0
(jQuery 3.7.1 logged the same 6 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node each.mjs

return false stopped $.each after the lamp, while forEach went on to the fan: it ignores what its callback returns. for...of with break stopped where $.each did, and items.entries() gave it the index too. Three more lines matter when you translate an old loop, and the table after them sums up how each loop stops and treats a hole. Inside a function callback, this is the value, but outside strict mode JavaScript wraps it in an object, as jQuery’s documentation warns: this == value was true and this === value false. An array with a hole, [1, , 3], gave $.each the index 1 with undefined, as for...of did, while forEach skipped it. For a plain object, Object.entries() gives for...of the same key and value pairs.

Loop To stop early A hole in the array
$.each(array, fn) return false visited, as undefined
array.forEach(fn) not possible skipped
for...of over array.entries() break visited, as undefined

Moving return false into forEach

Wrong: items.forEach(...) with return false inside, which runs over every item. Right: for...of with break, over items.entries() when you need the index, or items.some(...) when the loop searches for the first match.

Checkpoint

Check: $.each and native loops

  1. Question 1 of 2 A loop moves from $.each to items.forEach(), and keeps return false where the item is the lamp. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The loop runs over every item; forEach() ignores the false

    Use for...of with break, over items.entries() when the index is needed, to keep the early stop.

  2. Question 2 of 2 Which loops visit index 1 of the array [1, , 3], whose middle position was never set?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: $.each and for...of, with the value undefined

    A sparse array is rare in code written today, but a translation that moves a $.each loop to forEach() changes which items it visits.

$.map flattens what the callback returns and drops null

$.map(collection, callback) builds a new array from what the callback returns, with two rules that Array.prototype.map() lacks: a result that is null or undefined is removed, and a result that is an array is flattened into the result (jQuery.map()). The callback also gets the value first and the index second, the other way round from $.each. The order report maps four orders to their SKUs:

The order report and map.js

orders/map.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Order report</title>
  </head>
  <body>
    <ul id="items">
      <li>kettle</li>
      <li>lamp</li>
    </ul>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="map.js"></script>
  </body>
</html>

orders/map.js

// map.js: $.map and $.grep against map(), flatMap() and filter(), and the order of the arguments each callback gets.
const show = (list) => `[${list.map((v) => (v === undefined ? 'undefined' : JSON.stringify(v))).join(', ')}]`;
const orders = [
  { id: 'A1', skus: ['7310', '0042'] },
  { id: 'A2', skus: [] },
  { id: 'A3', skus: null },
  { id: 'A4', skus: ['5521'] },
];

console.log(`$.map(orders, o => o.skus): ${show($.map(orders, (o) => o.skus))}`);
console.log(`orders.map(o => o.skus): ${show(orders.map((o) => o.skus))}`);
console.log(`orders.flatMap(o => o.skus ?? []): ${show(orders.flatMap((o) => o.skus ?? []))}`);
console.log(`$.map(orders, o => o.note): ${show($.map(orders, (o) => o.note))}; orders.map(o => o.note): ${show(orders.map((o) => o.note))}`);
console.log(`one level only: $.map([[1, [2]]], x => x) ${show($.map([[1, [2]]], (x) => x))}; flatMap ${show([[1, [2]]].flatMap((x) => x ?? []))}`);

// An object: $.map passes value, then key.
const stock = { kettle: 12, lamp: 0 };
console.log(`$.map(stock, (v, k) => k + ':' + v): ${show($.map(stock, (v, k) => `${k}:${v}`))}; Object.entries: ${show(Object.entries(stock).map(([k, v]) => `${k}:${v}`))}`);

// $.grep keeps the items the callback accepts; with true as its third argument, the ones it rejects.
const counts = [12, 0, 5, 0];
console.log(`$.grep(counts, n => n > 0): ${show($.grep(counts, (n) => n > 0))}; with true: ${show($.grep(counts, (n) => n > 0, true))}`);
console.log(`counts.filter(n => n > 0): ${show(counts.filter((n) => n > 0))}; filter(n => !(n > 0)): ${show(counts.filter((n) => !(n > 0)))}`);

// What each callback receives first and second.
const first2 = (args) => [...args].slice(0, 2).map((v) => (v && v.nodeType === 1 ? `<${v.tagName.toLowerCase()}>` : JSON.stringify(v))).join(', ');
let seen;
$.each(['kettle'], function () { seen = first2(arguments); });
console.log(`$.each(array, fn) calls fn(${seen})`);
$.map(['kettle'], function () { seen = first2(arguments); return null; });
console.log(`$.map(array, fn) calls fn(${seen})`);
$.grep(['kettle'], function () { seen = first2(arguments); return true; });
console.log(`$.grep(array, fn) calls fn(${seen})`);
$('#items li').first().each(function () { seen = first2(arguments); });
console.log(`$('li').each(fn) calls fn(${seen})`);
$('#items li').first().map(function () { seen = first2(arguments); return null; });
console.log(`$('li').map(fn) calls fn(${seen})`);
['kettle'].forEach(function () { seen = first2(arguments); });
console.log(`array.forEach(fn) and array.map(fn) call fn(${seen})`);
What map.js logged JavaScript · map.mjs
// Prints what orders/map.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('orders/map.html');

Output

$.map(orders, o => o.skus): ["7310", "0042", "5521"]
orders.map(o => o.skus): [["7310","0042"], [], null, ["5521"]]
orders.flatMap(o => o.skus ?? []): ["7310", "0042", "5521"]
$.map(orders, o => o.note): []; orders.map(o => o.note): [undefined, undefined, undefined, undefined]
one level only: $.map([[1, [2]]], x => x) [1, [2]]; flatMap [1, [2]]
$.map(stock, (v, k) => k + ':' + v): ["kettle:12", "lamp:0"]; Object.entries: ["kettle:12", "lamp:0"]
$.grep(counts, n => n > 0): [12, 5]; with true: [0, 0]
counts.filter(n => n > 0): [12, 5]; filter(n => !(n > 0)): [0, 0]
$.each(array, fn) calls fn(0, "kettle")
$.map(array, fn) calls fn("kettle", 0)
$.grep(array, fn) calls fn("kettle", 0)
$('li').each(fn) calls fn(0, <li>)
$('li').map(fn) calls fn(0, <li>)
array.forEach(fn) and array.map(fn) call fn("kettle", 0)
(jQuery 3.7.1 logged the same 14 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node map.mjs

$.map(orders, o => o.skus) gave one flat list of three SKUs: the two arrays were spread out, the empty one added nothing and null was dropped. orders.map() kept four entries, arrays and null included, while flatMap(o => o.skus ?? []) matched $.map exactly: flatMap() also flattens one level, and ?? [] turns a missing value into nothing. A callback that returns nothing at all gave $.map an empty array and map() four undefined entries. $.grep(counts, fn) is filter(), and its third argument, true, keeps the items the callback rejects (jQuery.grep()).

The last lines of the run are the trap of every translation: each function orders its callback’s arguments its own way, and swapping two names gives no error.

Call The callback receives
$.each(array, fn) (index, value)
$.map(array, fn), $.grep(array, fn) (value, index)
$('li').each(fn), $('li').map(fn) (index, element)
array.forEach(fn), array.map(fn), array.filter(fn) (value, index)

Checkpoint

Check: $.map, $.grep and their arguments

  1. Question 1 of 2 orders holds four orders whose skus are ["7310", "0042"], [], null and ["5521"]. Which call gives the same array as $.map(orders, o => o.skus)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: orders.flatMap(o => o.skus ?? [])

    $.map leaves out null and undefined and flattens an array result one level; flatMap() does the second, and ?? [] does the first.

  2. Question 2 of 2 What does $.map(list, fn) pass to fn first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The value

    $.map and $.grep pass the value first, like the array methods; $.each and the collection methods pass the index first.

$.inArray returns a position, so a find at 0 looks like a miss

$.inArray(value, array) returns the position of the value, or −1 when it is not there, and its documentation warns that 0, the first position, is loosely equal to false, so the test must compare with −1 (jQuery.inArray()). The checkout of this page offers a payment method when its old test says so:

The checkout and inarray.js

orders/inarray.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Order report</title>
  </head>
  <body>
    <ul id="items">
      <li>kettle</li>
      <li>lamp</li>
    </ul>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="inarray.js"></script>
  </body>
</html>

orders/inarray.js

// inarray.js: $.inArray returns a position, so the first position, 0, is falsy and "not found", -1, is truthy.
const methods = ['card', 'cash', 'wallet'];
const old = (m) => ($.inArray(m, methods) ? 'offered' : 'not offered'); // the checkout's old test

console.log(`$.inArray: card ${$.inArray('card', methods)}, cash ${$.inArray('cash', methods)}, cheque ${$.inArray('cheque', methods)}`);
console.log(`if ($.inArray(m, methods)): card ${old('card')}, cash ${old('cash')}, cheque ${old('cheque')}`);

const fixed = (m) => ($.inArray(m, methods) !== -1 ? 'offered' : 'not offered');
console.log(`if ($.inArray(m, methods) !== -1): card ${fixed('card')}, cash ${fixed('cash')}, cheque ${fixed('cheque')}`);
console.log(`methods.includes(m): card ${methods.includes('card')}, cash ${methods.includes('cash')}, cheque ${methods.includes('cheque')}`);

// The comparison is strict, as indexOf's is; includes() differs only for NaN.
console.log(`'5' in [5]: $.inArray ${$.inArray('5', [5])}, indexOf ${[5].indexOf('5')}, includes ${[5].includes('5')}`);
console.log(`NaN in [NaN]: $.inArray ${$.inArray(NaN, [NaN])}, indexOf ${[NaN].indexOf(NaN)}, includes ${[NaN].includes(NaN)}`);
What inarray.js logged JavaScript · inarray.mjs
// Prints what orders/inarray.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('orders/inarray.html');

Output

$.inArray: card 0, cash 1, cheque -1
if ($.inArray(m, methods)): card not offered, cash offered, cheque offered
if ($.inArray(m, methods) !== -1): card offered, cash offered, cheque not offered
methods.includes(m): card true, cash true, cheque false
'5' in [5]: $.inArray -1, indexOf -1, includes false
NaN in [NaN]: $.inArray -1, indexOf -1, includes true
(jQuery 3.7.1 logged the same 6 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node inarray.mjs

The old test was wrong both ways: card, at position 0, was not offered, and cheque, which is not in the list, was offered, because −1 is truthy. Comparing with !== -1 fixed it, and methods.includes(m) says what the test means. The comparison is strict, as it is in indexOf(), so none of the three found the text '5' in [5]. includes() also finds NaN, which the other two never find.

Key idea

Translate a $.inArray test by what it asks: includes() for “is it there”, and indexOf() only when the code needs the position.

Checkpoint

Check: $.inArray and includes()

  1. One question methods is ["card", "cash", "wallet"]. The checkout tries m = "card", "cash" and "cheque". Which does if ($.inArray(m, methods)) treat as offered?

    Choose one answer.

    Show the answer: One question

    Answer: cash and cheque, but not card

    Compare with !== -1, or use methods.includes(m), which returns true or false.

$.extend skips undefined, and its deep form merges arrays by index

$.extend(target, ...sources) copies the keys of each source into the target, left to right, and returns the target. It skips values that are undefined, and with true as its first argument it merges nested objects and arrays instead of replacing them (jQuery.extend()). Object.assign() and spread look the same at a glance. The order report merges the options of its orders table into the defaults four ways:

The order report and extend.js

orders/extend.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Order report</title>
  </head>
  <body>
    <ul id="items">
      <li>kettle</li>
      <li>lamp</li>
    </ul>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="extend.js"></script>
  </body>
</html>

orders/extend.js

// extend.js: $.extend against Object.assign and spread, shallow and deep, on an orders table's defaults and options.
const show = (v) => JSON.stringify(v, (key, value) => (value === undefined ? '(undefined)' : value));
const defaults = () => ({ theme: 'light', pageSize: 20, columns: ['sku', 'name', 'price'], labels: { empty: 'No orders', more: 'Load more' } });
const options = { theme: undefined, pageSize: 50, columns: ['sku'], labels: { empty: 'Nothing yet' } };

console.log(`$.extend({}, defaults, options): ${show($.extend({}, defaults(), options))}`);
console.log(`Object.assign({}, defaults, options): ${show(Object.assign({}, defaults(), options))}`);
console.log(`{ ...defaults, ...options }: ${show({ ...defaults(), ...options })}`);
console.log(`$.extend(true, {}, defaults, options): ${show($.extend(true, {}, defaults(), options))}`);

// A shallow copy shares the nested objects; a deep one does not.
const base = defaults();
const copy = $.extend({}, base);
copy.labels.empty = 'Changed in the copy';
console.log(`after copy.labels.empty = 'Changed in the copy': base.labels.empty ${show(base.labels.empty)}; copy.labels === base.labels ${copy.labels === base.labels}`);
const deep = $.extend(true, {}, base);
console.log(`$.extend(true, {}, base): deep.labels === base.labels ${deep.labels === base.labels}, deep.columns === base.columns ${deep.columns === base.columns}`);

// A native merge with $.extend(true)'s rules: undefined values skipped, plain objects and arrays merged key by key,
// and the key __proto__ skipped, as jQuery has done since 3.4.0.
const isPlain = (v) => v !== null && typeof v === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(v));
function merge(target, ...sources) {
  for (const source of sources) {
    for (const [key, value] of Object.entries(source)) {
      if (key === '__proto__' || value === undefined) continue;
      if (Array.isArray(value)) target[key] = merge(Array.isArray(target[key]) ? target[key] : [], value);
      else if (isPlain(value)) target[key] = merge(isPlain(target[key]) ? target[key] : {}, value);
      else target[key] = value;
    }
  }
  return target;
}
console.log(`merge({}, defaults, options): ${show(merge({}, defaults(), options))}`);

// Settings saved as JSON and read back, with a key that names the prototype.
const saved = JSON.parse('{"pageSize": 10, "__proto__": {"isAdmin": true}}');
const viaJquery = $.extend(true, {}, defaults(), saved);
const viaMerge = merge({}, defaults(), saved);
console.log(`with a "__proto__" key: $.extend(true) gives pageSize ${viaJquery.pageSize}, isAdmin ${viaJquery.isAdmin}; merge() gives pageSize ${viaMerge.pageSize}, isAdmin ${viaMerge.isAdmin}; ({}).isAdmin ${({}).isAdmin}`);
What extend.js logged JavaScript · extend.mjs
// Prints what orders/extend.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('orders/extend.html');

Output

$.extend({}, defaults, options): {"theme":"light","pageSize":50,"columns":["sku"],"labels":{"empty":"Nothing yet"}}
Object.assign({}, defaults, options): {"theme":"(undefined)","pageSize":50,"columns":["sku"],"labels":{"empty":"Nothing yet"}}
{ ...defaults, ...options }: {"theme":"(undefined)","pageSize":50,"columns":["sku"],"labels":{"empty":"Nothing yet"}}
$.extend(true, {}, defaults, options): {"theme":"light","pageSize":50,"columns":["sku","name","price"],"labels":{"empty":"Nothing yet","more":"Load more"}}
after copy.labels.empty = 'Changed in the copy': base.labels.empty "Changed in the copy"; copy.labels === base.labels true
$.extend(true, {}, base): deep.labels === base.labels false, deep.columns === base.columns false
merge({}, defaults, options): {"theme":"light","pageSize":50,"columns":["sku","name","price"],"labels":{"empty":"Nothing yet","more":"Load more"}}
with a "__proto__" key: $.extend(true) gives pageSize 10, isAdmin undefined; merge() gives pageSize 10, isAdmin undefined; ({}).isAdmin undefined
(jQuery 3.7.1 logged the same 8 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node extend.mjs

The first four lines hold three differences. theme: undefined kept the default with $.extend and became undefined with Object.assign() and spread. The shallow forms replaced labels whole, so its "more" text was lost, and only the deep form merged it. And the deep form merged columns by index: the options’ ["sku"] replaced the first of three columns and kept the other two, which is rarely what an option that names the columns means. The next two lines show the cost of a shallow copy: A new object whose properties hold the same values as the original's, so a nested object or array is shared, not copied: changing it through the copy changes the original. $.extend({}, obj), Object.assign() and spread make one.: the copy and the original shared one labels object, so changing the copy changed the original.

structuredClone() makes a deep copy: A copy in which nested objects and arrays are copied too, so changing the copy never changes the original. structuredClone() makes one of plain data; $.extend(true, {}, obj) makes one of plain objects and arrays. that shares nothing, Dates included, but it copies one object and merges nothing, and it refuses functions (HTML Standard). It needs no page, so this program runs on Node.js:

A deep copy with structuredClone() JavaScript · clone.mjs
// clone.mjs: structuredClone() makes a deep copy of plain data and refuses what is not data. It needs no page, so this
// program runs on Node.js itself.
const base = { theme: 'light', columns: ['sku', 'name'], labels: { empty: 'No orders' }, since: new Date(0) };
const copy = structuredClone(base);
copy.labels.empty = 'Changed in the copy';
console.log(`base.labels.empty ${JSON.stringify(base.labels.empty)}; copy.columns === base.columns ${copy.columns === base.columns}; copy.since is a Date ${copy.since instanceof Date}`);

try {
  structuredClone({ ...base, onRow: () => {} });
} catch (error) {
  console.log(`a copy of an object with a function in it: ${error.name}`);
}

Output

base.labels.empty "No orders"; copy.columns === base.columns false; copy.since is a Date true
a copy of an object with a function in it: DataCloneError

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node clone.mjs

When code merges options into defaults, write its rules down, as the page’s merge() does: skip undefined, merge plain objects key by key, and decide what an array means. Answer four questions about one $.extend call of your own, and the table marks what can replace it:

Interactive · answer and compare What should replace this $.extend() call? Answer four questions about one $.extend() call in your code, and the table marks the replacements that fit.
What does the call do?
Can an option be undefined, meaning "use the default"?
Must an array in the options replace the default array?
Do the values include functions?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to copy or merge objects, against the four things that decide most $.extend() replacements
Replacement undefined in the options Nested objects Arrays in the options Functions inside
$.extend({}, a, b) Works on: the top-level keys Good skipped: theme stayed "light" Poor replaced whole: labels lost its "more" text Mixed replaced, but the result shares the array Good copied by reference
$.extend(true, {}, a, b) Works on: every level Good skipped: theme stayed "light" Good merged: labels kept "more" and took the new "empty" Poor merged by index: ["sku"] over three columns kept all three Good copied by reference
Object.assign() or spread Works on: the top-level keys Poor copied: theme became undefined Poor replaced whole: labels lost its "more" text Mixed replaced, but the result shares the array Good copied by reference
structuredClone(a) Works on: every level of one object Mixed kept as it is: it copies one object and merges nothing Good copied at every level: nothing is shared Good copied, never merged Poor throws DataCloneError on a function
A merge you write Works on: the rules you choose Good skipped, when the merge skips it as the lesson's merge() does Good merged key by key Good merged or replaced, as you decide Good copied by reference
How the answers decide
  • “What does the call do?” Copies one object so that nothing is shared: rules out $.extend({}, a, b) and Object.assign() or spread. A shallow copy shares the nested objects: changing copy.labels.empty changed the original in the run.
  • “What does the call do?” Merges options into defaults: rules out structuredClone(a). structuredClone() copies one object and merges nothing.
  • “What does the call do?” Merges options into defaults: adds a caveat to $.extend({}, a, b). A shallow $.extend() merges only the top level: labels was replaced whole and lost its "more" text in the run.
  • “Can an option be undefined, meaning "use the default"?” Yes: rules out Object.assign() or spread. Object.assign() and spread copy undefined values: theme became undefined in the run, where $.extend() kept "light".
  • “Must an array in the options replace the default array?” Yes, the options say which columns to show: rules out $.extend(true, {}, a, b). $.extend(true) merges arrays by index, so ["sku"] over three columns kept all three in the run.
  • “Do the values include functions?” Yes, callbacks such as onRow: rules out structuredClone(a). structuredClone() refuses functions: it threw DataCloneError in the Node.js run.

A merge of saved data skips __proto__

Settings read back from JSON can hold the key __proto__. Before 3.4.0, a deep $.extend copied it and added properties to Object.prototype, which every object inherits; this prototype pollution: A flaw in which code that merges or copies data it did not write follows a key such as __proto__ into Object.prototype, so every object of the program inherits the added property. jQuery 3.4.0 and later skip that key in $.extend(). is CVE: Common Vulnerabilities and Exposures: the public list that gives each known security flaw an identifier such as CVE-2020-11023, so that advisories, scanners and fixes all refer to the same problem. More in “jQuery in 2026: versions, support and where it runs”-2019-11358 (CVE record). jQuery 3.4.0 and later skip the key, and so must any merge you write: in the last line of the run, both left isAdmin undefined.

Checkpoint

Check: Copies and merges

  1. Question 1 of 2 The defaults say theme: "light" and the options theme: undefined. What is theme after Object.assign({}, defaults, options)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: undefined

    Remove undefined values from the options first, or use a merge that skips them, when undefined means "use the default".

  2. Question 2 of 2 The defaults have columns ["sku", "name", "price"] and the options columns ["sku"]. What does $.extend(true, {}, defaults, options) give for columns?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ["sku", "name", "price"]

    When an array in the options should replace the default, use a merge that replaces arrays, or set the array after a deep merge.

$.param writes brackets that PHP reads; URLSearchParams writes text

$.param(object) turns an object into a form body, and for arrays and nested objects it writes the bracket names that PHP and Ruby on Rails read: items[] for each item of a list, address[city] for each key of an object (jQuery.param()). new URLSearchParams(object) takes each value as text instead, because its constructor reads an object as a record of strings (URL Standard). Step through one order sent both ways:

Interactive · message by message One order, two encodings, and what PHP receives Step through the order as each encoder writes it and PHP reads it back, and compare the two arrivals.
One order, two encodings, and what PHP receives, step 6 of 6. Messages so far: The order page to The request body: items[]=SKU-7310&…; The request body to PHP's $_POST: items, address as arrays; The order page to The request body: items=SKU-7310%2CSKU-0042; The request body to PHP's $_POST: two strings, no error; The order page to The request body: items[]=…&address[city]=….The order pageitems, address, noteThe request bodyitems[], address[city]PHP's $_POSTitems: a list of 2 againitems[]=SKU-7310&…items, address as arraysitems=SKU-7310%2CSKU-0042two strings, no erroritems[]=…&address[city]=…

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The order. The page holds one order: items, a list of two SKUs; address, an object with a city and a PIN code; and a note.
  2. $.param writes brackets. $.param(order) names each item of the list items[], and each key of the address address[city] and address[pin]: the form that PHP and Ruby on Rails read.
  3. PHP rebuilds the order. PHP turns the two items[] pairs into a list of two, and address[city] and address[pin] into an array: the server sees the order as the page had it.
  4. URLSearchParams writes text. new URLSearchParams(order) turns each value into text: the list becomes "SKU-7310,SKU-0042", and the address becomes "[object Object]".
  5. PHP gets two strings. PHP receives items as one string and address as the text [object Object]. The order has lost its structure, and nothing reported an error on either side.
  6. Brackets without jQuery. bracketParams(order) appends the same bracket names to a URLSearchParams, so PHP gets its list and its array back with no jQuery on the page.

The order page printed each body, and also the traditional form, $.param(order, true), which repeats items= without brackets and turns the address into text as well:

The order page and param.js

orders/param.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Order report</title>
  </head>
  <body>
    <ul id="items">
      <li>kettle</li>
      <li>lamp</li>
    </ul>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="param.js"></script>
  </body>
</html>

orders/param.js

// param.js: one order encoded as a form body with $.param, with new URLSearchParams() and with a native encoder that
// writes the same bracket names as $.param.
const order = { items: ['SKU-7310', 'SKU-0042'], address: { city: 'Pune', pin: '411001' }, note: 'leave at gate' };

const viaParam = $.param(order);
console.log(`$.param(order): ${viaParam}`);
console.log(`  decoded: ${decodeURIComponent(viaParam)}`);

const viaSearchParams = new URLSearchParams(order).toString();
console.log(`new URLSearchParams(order): ${viaSearchParams}`);
console.log(`  decoded: ${decodeURIComponent(viaSearchParams.replaceAll('+', ' '))}`);

console.log(`$.param(order, true), the traditional form: ${$.param(order, true)}`);

/**
 * Appends a value as $.param names it: items[] for the items of an array (items[0][...] for objects in it) and
 * address[city] for the keys of an object.
 */
function bracketParams(value, prefix = '', params = new URLSearchParams()) {
  if (Array.isArray(value)) {
    value.forEach((v, i) => bracketParams(v, `${prefix}[${v !== null && typeof v === 'object' ? i : ''}]`, params));
  } else if (value !== null && typeof value === 'object') {
    for (const [key, v] of Object.entries(value)) bracketParams(v, prefix ? `${prefix}[${key}]` : key, params);
  } else {
    params.append(prefix, value ?? '');
  }
  return params;
}
const viaBrackets = bracketParams(order).toString();
console.log(`bracketParams(order): ${viaBrackets}`);
console.log(`  the same pairs as $.param: ${decodeURIComponent(viaBrackets.replaceAll('+', ' ')) === decodeURIComponent(viaParam)}`);
What param.js logged JavaScript · param.mjs
// Prints what orders/param.js logged in jsdom (recorded.mjs).
import { show } from './show.mjs';

show('orders/param.html');

Output

$.param(order): items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune&address%5Bpin%5D=411001&note=leave%20at%20gate
  decoded: items[]=SKU-7310&items[]=SKU-0042&address[city]=Pune&address[pin]=411001&note=leave at gate
new URLSearchParams(order): items=SKU-7310%2CSKU-0042&address=%5Bobject+Object%5D&note=leave+at+gate
  decoded: items=SKU-7310,SKU-0042&address=[object Object]&note=leave at gate
$.param(order, true), the traditional form: items=SKU-7310&items=SKU-0042&address=%5Bobject%20Object%5D&note=leave%20at%20gate
bracketParams(order): items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune&address%5Bpin%5D=411001&note=leave+at+gate
  the same pairs as $.param: true
(jQuery 3.7.1 logged the same 7 lines)

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node param.mjs

PHP reads bracket names in a request as arrays (PHP manual), and its parse_str() parses a body “as if it were the query string passed via a URL” (parse_str). This program decodes four of the bodies the page printed:

What PHP makes of four of the bodies PHP · decode.php
<?php

// decode.php: what PHP makes of four form bodies that the order page printed (orders/param.js). parse_str() parses a
// string "as if it were the query string passed via a URL", so name[] becomes a list and name[key] an array.

$bodies = [
    '$.param(order)' => 'items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune'
        . '&address%5Bpin%5D=411001&note=leave%20at%20gate',
    'new URLSearchParams(order)' => 'items=SKU-7310%2CSKU-0042&address=%5Bobject+Object%5D&note=leave+at+gate',
    '$.param(order, true)' => 'items=SKU-7310&items=SKU-0042&address=%5Bobject%20Object%5D&note=leave%20at%20gate',
    'bracketParams(order)' => 'items%5B%5D=SKU-7310&items%5B%5D=SKU-0042&address%5Bcity%5D=Pune'
        . '&address%5Bpin%5D=411001&note=leave+at+gate',
];

foreach ($bodies as $from => $body) {
    parse_str($body, $fields);
    $items = is_array($fields['items'])
        ? 'a list of ' . count($fields['items'])
        : 'the text "' . $fields['items'] . '"';
    echo $from, ': ', json_encode($fields, JSON_UNESCAPED_SLASHES), "\n";
    echo '  items is ', $items, "\n";
}

Output

$.param(order): {"items":["SKU-7310","SKU-0042"],"address":{"city":"Pune","pin":"411001"},"note":"leave at gate"}
  items is a list of 2
new URLSearchParams(order): {"items":"SKU-7310,SKU-0042","address":"[object Object]","note":"leave at gate"}
  items is the text "SKU-7310,SKU-0042"
$.param(order, true): {"items":"SKU-0042","address":"[object Object]","note":"leave at gate"}
  items is the text "SKU-0042"
bracketParams(order): {"items":["SKU-7310","SKU-0042"],"address":{"city":"Pune","pin":"411001"},"note":"leave at gate"}
  items is a list of 2

Recorded with PHP 8.5.11 (cli) (NTS) on macOS 26 arm64. To run it yourself: php decode.php

From $.param, PHP got a list of two items and an address array. From URLSearchParams, it got items as the single text "SKU-7310,SKU-0042" and address as "[object Object]": the order lost its structure, and neither side reported an error. From the traditional form it kept only the last of the two items= values. bracketParams() appends the same names as $.param to a URLSearchParams, so PHP got its arrays back. It writes a space as + where $.param writes %20, and both mean a space in a form body.

Swapping $.param for URLSearchParams in one step

Wrong: new URLSearchParams(order) for a body with arrays or nested objects, which sends "[object Object]". Right: build the bracket names, as bracketParams() does, or send JSON to a server that reads JSON; for a flat object of strings the two give the same pairs.

Checkpoint

Check: $.param and URLSearchParams

  1. Question 1 of 2 What does new URLSearchParams({ items: ["SKU-7310", "SKU-0042"] }).toString() give?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: items=SKU-7310%2CSKU-0042

    An array or an object in URLSearchParams's argument is silently turned into text, so build the pairs yourself when the server expects brackets.

  2. Question 2 of 2 What did PHP's parse_str() make of items%5B%5D=SKU-7310&items%5B%5D=SKU-0042?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A list of two items

    Bracket names are a convention of PHP and Ruby on Rails, which $.param follows; URLSearchParams does not write them.

jQuery 4 removed eleven utilities that native code replaces

jQuery 4.0 removed eleven utilities that jQuery 3 had deprecated, release by release, and the upgrade guide gives a native replacement for most of them (4.0 upgrade guide). Walk through when each one went:

Timeline · 5 events in order How eleven jQuery utilities went, release by release Walk through the releases in order to see when each utility was marked for removal, and what replaces it.
  1. 3.0

    parseJSON() and unique() deprecated deprecated

    jQuery.parseJSON() had become the same as the browser's JSON.parse(), and jQuery.unique() was renamed jQuery.uniqueSort(), a name that says it sorts. The undocumented jQuery.camelCase() was deprecated in the same release.

  2. 3.2

    isArray() deprecated deprecated

    Array.isArray() does the same in every browser jQuery 3 supports. The never-documented jQuery.nodeName() was deprecated here too.

  3. 3.3

    isFunction(), isNumeric(), isWindow(), now() and type() deprecated deprecated

    typeof value === "function" and Date.now() replace two of them directly; isNumeric(), isWindow() and type() need a test chosen for each use, because no one native call gives the same answers.

  4. 3.5

    trim() deprecated deprecated

    String.prototype.trim() strips the same spaces, but it is a method of strings: jQuery.trim(null) returned "", while null.trim() throws a TypeError.

  5. 4.0

    All eleven removed removed

    On jQuery 4.0.0 each of them is undefined, so a call throws "is not a function". jQuery Migrate 4.0.2 brings every one back with a warning that names it, which is how an upgrade finds the calls.

The timeline has 5 events, from 3.0 to 4.0.

The order report calls the ones that have a direct replacement, on jQuery 4.0.0, on 3.7.1, and on 4.0.0 with jQuery Migrate 4.0.2:

The order report and removed.js

orders/removed.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Order report</title>
  </head>
  <body>
    <ul id="items">
      <li>kettle</li>
      <li>lamp</li>
    </ul>
    <script src="https://code.jquery.com/jquery-4.0.0.min.js" integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
    <script src="removed.js"></script>
  </body>
</html>

orders/removed.js

// removed.js: which of the utilities removed in jQuery 4 this jQuery has, and the native code that replaces each one.
const names = ['trim', 'isArray', 'parseJSON', 'isFunction', 'isNumeric', 'type', 'isWindow', 'now', 'camelCase', 'nodeName', 'unique'];
console.log(`jQuery ${$.fn.jquery} has: ${names.filter((n) => typeof $[n] === 'function').join(', ') || 'none of them'}`);
const kept = ['each', 'map', 'grep', 'extend', 'inArray', 'param', 'isPlainObject', 'isEmptyObject', 'contains', 'uniqueSort', 'escapeSelector', 'parseHTML', 'noop'];
console.log(`and keeps: ${kept.filter((n) => typeof $[n] === 'function').join(', ')}`);

/** Runs one call and says what it returned, or what it threw. */
const run = (label, fn) => {
  try {
    return `${label} ${JSON.stringify(fn())}`;
  } catch (error) {
    return `${label} throws ${error.name}`;
  }
};
const city = '  Pune  ';
const nothing = null;
function onRow() {}

console.log([run('$.trim(city)', () => $.trim(city)), run('city.trim()', () => city.trim())].join(' | '));
console.log([run('$.trim(nothing)', () => $.trim(nothing)), run('nothing.trim()', () => nothing.trim()), run("String(nothing ?? '').trim()", () => String(nothing ?? '').trim())].join(' | '));
console.log([run('$.isArray([1])', () => $.isArray([1])), run('Array.isArray([1])', () => Array.isArray([1]))].join(' | '));
console.log([run(`$.parseJSON('{"a":1}')`, () => $.parseJSON('{"a":1}')), run(`JSON.parse('{"a":1}')`, () => JSON.parse('{"a":1}'))].join(' | '));
console.log([run('$.isFunction(onRow)', () => $.isFunction(onRow)), run("typeof onRow === 'function'", () => typeof onRow === 'function')].join(' | '));
console.log([run('typeof $.now()', () => typeof $.now()), run('typeof Date.now()', () => typeof Date.now())].join(' | '));
// isNumeric has no one-to-one replacement: the upgrade guide's Number.isFinite() treats strings differently.
console.log([run("$.isNumeric('12')", () => $.isNumeric('12')), run("Number.isFinite('12')", () => Number.isFinite('12')), run("Number.isFinite(Number('12'))", () => Number.isFinite(Number('12')))].join(' | '));
console.log([run("$.isNumeric('')", () => $.isNumeric('')), run("Number.isFinite(Number(''))", () => Number.isFinite(Number('')))].join(' | '));
What removed.js logged, then the same page with jQuery Migrate 4.0.2 JavaScript · removed.mjs
// Prints what orders/removed.js logged in jsdom: on jQuery 4.0.0 and 3.7.1, then on jQuery 4.0.0 with jQuery Migrate
// 4.0.2 (recorded.mjs).
import { show } from './show.mjs';

show('orders/removed.html');
console.log('jQuery 4.0.0 with jQuery Migrate 4.0.2:');
show('orders/removed-migrate.html');

Output

jQuery 4.0.0 has: none of them
and keeps: each, map, grep, extend, inArray, param, isPlainObject, isEmptyObject, contains, uniqueSort, escapeSelector, parseHTML, noop
$.trim(city) throws TypeError | city.trim() "Pune"
$.trim(nothing) throws TypeError | nothing.trim() throws TypeError | String(nothing ?? '').trim() ""
$.isArray([1]) throws TypeError | Array.isArray([1]) true
$.parseJSON('{"a":1}') throws TypeError | JSON.parse('{"a":1}') {"a":1}
$.isFunction(onRow) throws TypeError | typeof onRow === 'function' true
typeof $.now() throws TypeError | typeof Date.now() "number"
$.isNumeric('12') throws TypeError | Number.isFinite('12') false | Number.isFinite(Number('12')) true
$.isNumeric('') throws TypeError | Number.isFinite(Number('')) true
(jQuery 3.7.1 logged other lines:)
  line 1: jQuery 3.7.1 has: trim, isArray, parseJSON, isFunction, isNumeric, type, isWindow, now, camelCase, nodeName, unique
  line 3: $.trim(city) "Pune" | city.trim() "Pune"
  line 4: $.trim(nothing) "" | nothing.trim() throws TypeError | String(nothing ?? '').trim() ""
  line 5: $.isArray([1]) true | Array.isArray([1]) true
  line 6: $.parseJSON('{"a":1}') {"a":1} | JSON.parse('{"a":1}') {"a":1}
  line 7: $.isFunction(onRow) true | typeof onRow === 'function' true
  line 8: typeof $.now() "number" | typeof Date.now() "number"
  line 9: $.isNumeric('12') true | Number.isFinite('12') false | Number.isFinite(Number('12')) true
  line 10: $.isNumeric('') false | Number.isFinite(Number('')) true
jQuery 4.0.0 with jQuery Migrate 4.0.2:
JQMIGRATE: Migrate is installed with logging active, version 4.0.2
jQuery 4.0.0 has: trim, isArray, parseJSON, isFunction, isNumeric, type, isWindow, now, camelCase, nodeName, unique
and keeps: each, map, grep, extend, inArray, param, isPlainObject, isEmptyObject, contains, uniqueSort, escapeSelector, parseHTML, noop
(warning) JQMIGRATE: jQuery.trim() is removed; use String.prototype.trim
$.trim(city) "Pune" | city.trim() "Pune"
$.trim(nothing) "" | nothing.trim() throws TypeError | String(nothing ?? '').trim() ""
(warning) JQMIGRATE: jQuery.isArray() is removed; use Array.isArray()
$.isArray([1]) true | Array.isArray([1]) true
(warning) JQMIGRATE: jQuery.parseJSON is removed; use JSON.parse
$.parseJSON('{"a":1}') {"a":1} | JSON.parse('{"a":1}') {"a":1}
(warning) JQMIGRATE: jQuery.isFunction() is removed
$.isFunction(onRow) true | typeof onRow === 'function' true
(warning) JQMIGRATE: jQuery.now() is removed; use Date.now()
typeof $.now() "number" | typeof Date.now() "number"
(warning) JQMIGRATE: jQuery.isNumeric() is removed
$.isNumeric('12') true | Number.isFinite('12') false | Number.isFinite(Number('12')) true
$.isNumeric('') false | Number.isFinite(Number('')) true

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node removed.mjs

On 4.0.0 every call threw a TypeError, because the functions are gone. On 3.7.1 each returned what its replacement returned, with two exceptions that deserve a test. $.trim(null) gave "", while null.trim() throws, so code that may receive null needs String(value ?? '').trim(). And $.isNumeric('12') was true while the guide’s Number.isFinite('12') is false, because Number.isFinite() never converts a string; $.isNumeric('') was false while Number.isFinite(Number('')) is true. $.isNumeric, $.type, $.isWindow and $.camelCase have no native call that does the same: Migrate’s warnings advise a test that fits each use for the first two, removing the calls of the third (where the test is still needed, obj != null && obj === obj.window, the expression jQuery used), and a small utility of your own for the last (warnings.md). With Migrate 4.0.2 loaded, every function came back, and each call logged a warning that names the function.

jQuery 3 Native
$.trim(s) s.trim(), or String(s ?? '').trim() when s may be null
$.isArray(x), $.parseJSON(s) Array.isArray(x), JSON.parse(s)
$.isFunction(x), $.now() typeof x === 'function', Date.now()
$.nodeName(el, 'div') el.nodeName.toLowerCase() === 'div'
$.unique(list) $.uniqueSort(list), which jQuery 4 keeps
$.isNumeric, $.type, $.isWindow, $.camelCase a test or a utility chosen for each use

The second line of the run lists what jQuery 4 keeps: $.each, $.map, $.grep, $.extend, $.inArray and $.param themselves, and $.isPlainObject, $.isEmptyObject, $.contains, $.uniqueSort, $.escapeSelector, $.parseHTML and $.noop. None of them needs a change for the upgrade; replacing them is a later choice, made with the rules of this lesson.

Checkpoint

Check: Utilities removed in jQuery 4

  1. Question 1 of 2 Code calls $.trim(name), and name is sometimes null. What replaces it on jQuery 4 with the same results?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: String(name ?? '').trim()

    jQuery Migrate's advice is text.trim() when text is surely a string; a value that can be null needs the guard.

  2. Question 2 of 2 The upgrade guide suggests Number.isFinite() for $.isNumeric(). What does Number.isFinite('12') return, where $.isNumeric('12') was true?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: false, because Number.isFinite() never converts a string

    Decide what each call site means: a number already, a string of digits from a form, or something else; and write that test. Number('') is 0, so a blank field passes a careless one.

Practise: replace $.each, $.map and $.extend

Three helpers, one for each utility, give old code the same results without jQuery. Each helper is one step, with starting code, a first hint and sample tests taken from the order report’s own cases; take the steps in any order:

Code lab · 3 steps

Replace jQuery's utilities with native helpers

Write each(), map() and extend() that follow the rules of $.each, $.map and $.extend, the last one safe with saved JSON.

  1. Write each() with $.each's rules To do
  2. Write map() with $.map's rules To do
  3. Write extend() with $.extend's rules, safely To do

Exercise · Medium · JavaScript

Write each() with $.each's rules

Old code calls $.each() in many places, and the team wants a small each() that behaves the same, so the calls can move off jQuery one at a time. Write it in each.mjs:

  • For an array, or an array-like object such as { length: 3, 0: 'x', 2: 'z' }, call callback(index, value) for every index from 0 to length - 1, in order, holes included, with this set to the value.
  • For any other object, call callback(key, value) for each of its own keys, with this set to the value.
  • Stop as soon as the callback returns false; any other return value goes on to the next item.
  • Return the collection itself.

The tests run without a browser and without jQuery.

Starter code · each.mjs

/**
 * Calls callback(index, value) for each item of an array or an array-like object, or callback(key, value) for each own
 * key of an object, with this set to the value; stops when the callback returns false; returns the collection.
 * @param {any} collection @param {(this: any, indexOrKey: number | string, value: any) => unknown} callback
 */
export function each(collection, callback) {
  collection.forEach((value, index) => callback(value, index));
  return collection;
}
The sample tests · each.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { each } from './each.mjs';

test('passes the index first, then the value, with this set to the value', () => {
  const calls = [];
  each(['kettle', 'lamp'], function (index, value) {
    calls.push([index, value, this]);
  });
  assert.deepEqual(calls, [[0, 'kettle', 'kettle'], [1, 'lamp', 'lamp']]);
});

test('stops when the callback returns false, and returns the collection', () => {
  const items = ['kettle', 'lamp', 'clock', 'fan'];
  const seen = [];
  const result = each(items, (index, value) => {
    seen.push(value);
    if (value === 'lamp') return false;
    return 0;
  });
  assert.deepEqual(seen, ['kettle', 'lamp']);
  assert.equal(result, items);
});

test('visits every index of an array-like object, holes included', () => {
  const seen = [];
  each({ length: 3, 0: 'x', 2: 'z' }, (index, value) => seen.push(`${index}=${value}`));
  each([1, , 3], (index, value) => seen.push(`${index}=${value}`));
  assert.deepEqual(seen, ['0=x', '1=undefined', '2=z', '0=1', '1=undefined', '2=3']);
});

test('passes the key and the value of a plain object, and stops there too', () => {
  const seen = [];
  each({ kettle: 12, lamp: 0 }, (key, value) => seen.push([key, value]));
  assert.deepEqual(seen, [['kettle', 12], ['lamp', 0]]);
  const keys = [];
  each({ kettle: 12, lamp: 0, clock: 3 }, (key) => {
    keys.push(key);
    if (key === 'lamp') return false;
  });
  assert.deepEqual(keys, ['kettle', 'lamp']);
});
A hint

A plain for loop from 0 to length - 1 visits holes, which forEach() skips, and lets you stop with break. callback.call(value, index, value) passes the index first and sets this. Use Object.entries() for an object, and compare the result with === false, so that returning 0 or undefined does not stop the loop.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

Write map() with $.map's rules

Next, a map() that gives the same results as $.map(). Write it in map.mjs:

  • For an array or an array-like object, call callback(value, index) for every index from 0 to length - 1; for any other object, call callback(value, key) for each of its own keys. Note the order: the value comes first here, unlike $.each().
  • Return a new array of the results, where a result that is null or undefined is left out, and a result that is an array adds its items, one level deep only.

The tests run without a browser and without jQuery.

Starter code · map.mjs

/**
 * Calls callback(value, index) for each item of an array or an array-like object, or callback(value, key) for each own
 * key of an object, and returns the results: null and undefined left out, arrays added one level deep.
 * @param {any} collection @param {(value: any, indexOrKey: number | string) => unknown} callback
 */
export function map(collection, callback) {
  return collection.map(callback);
}
The sample tests · map.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { map } from './map.mjs';

test('passes the value first, then the index', () => {
  assert.deepEqual(map(['kettle', 'lamp'], (value, index) => `${index}:${value}`), ['0:kettle', '1:lamp']);
});

test('leaves out null and undefined', () => {
  assert.deepEqual(map([1, 2, 3, 4], (n) => (n % 2 ? n : null)), [1, 3]);
  assert.deepEqual(map(['kettle'], () => undefined), []);
});

test('adds the items of an array result, one level deep', () => {
  const orders = [{ skus: ['7310', '0042'] }, { skus: [] }, { skus: null }, { skus: ['5521'] }];
  assert.deepEqual(map(orders, (o) => o.skus), ['7310', '0042', '5521']);
  assert.deepEqual(map([1], () => [[1]]), [[1]]);
});

test('maps a plain object by key and an array-like object by index', () => {
  assert.deepEqual(map({ kettle: 12, lamp: 0 }, (value, key) => `${key}:${value}`), ['kettle:12', 'lamp:0']);
  assert.deepEqual(map({ length: 2, 0: 'x', 1: 'y' }, (value) => value.toUpperCase()), ['X', 'Y']);
});
A hint

Collect the results in an array as you go: skip a result when result == null, push(...result) when Array.isArray(result), and push(result) otherwise. A result such as [[1]] then adds [1], one level deep, as $.map() does.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Exercise · Medium · JavaScript

Write extend() with $.extend's rules, safely

Last, the settings code calls $.extend() both ways, shallow and deep, on defaults and on settings saved as JSON. Write extend(deep, target, ...sources) in extend.mjs:

  • Copy the own keys of each source into target, from left to right, so a later source wins, and return target.
  • Skip a value that is undefined, so an option left undefined keeps the default.
  • When deep is true, merge a plain object or an array key by key into a copy of its own, and never share it with a source: the defaults must not change when the result does. Arrays merge by index, as $.extend(true, …) does.
  • Always skip the key __proto__, as jQuery has done since 3.4.0, so saved JSON can never change the prototype of the result or of every object.

The tests run without a browser and without jQuery.

Starter code · extend.mjs

/**
 * Copies the own keys of each source into target and returns target, as $.extend does: undefined values skipped,
 * plain objects and arrays merged into copies when deep is true, and the key __proto__ always skipped.
 * @param {boolean} deep @param {Record<string, any>} target @param {...Record<string, any>} sources
 */
export function extend(deep, target, ...sources) {
  return Object.assign(target, ...sources);
}
The sample tests · extend.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { extend } from './extend.mjs';

const defaults = () => ({ theme: 'light', pageSize: 20, columns: ['sku', 'name', 'price'], labels: { empty: 'No orders', more: 'Load more' } });

test('skips undefined, lets a later source win and returns the target', () => {
  const target = { theme: 'light', pageSize: 20 };
  const result = extend(false, target, { theme: undefined, pageSize: 50 }, { pageSize: 30 });
  assert.equal(result, target);
  assert.deepEqual(target, { theme: 'light', pageSize: 30 });
});

test('shares nested objects when the copy is shallow', () => {
  const base = defaults();
  const copy = extend(false, {}, base);
  assert.equal(copy.labels, base.labels);
  assert.equal(copy.columns, base.columns);
});

test('merges objects and arrays into copies when deep is true', () => {
  const base = defaults();
  const result = extend(true, {}, base, { columns: ['id'], labels: { empty: 'Nothing yet' } });
  assert.deepEqual(result, { theme: 'light', pageSize: 20, columns: ['id', 'name', 'price'], labels: { empty: 'Nothing yet', more: 'Load more' } });
  assert.notEqual(result.labels, base.labels);
  assert.deepEqual(base, defaults());
  const source = { rows: [{ id: 1 }] };
  assert.notEqual(extend(true, {}, source).rows[0], source.rows[0], 'an object inside an array is copied too');
});

test('never lets a "__proto__" key from JSON change a prototype', () => {
  const saved = JSON.parse('{"pageSize": 10, "__proto__": {"isAdmin": true}}');
  const deep = extend(true, {}, saved);
  const shallow = extend(false, {}, saved);
  assert.equal(deep.pageSize, 10);
  assert.equal(shallow.pageSize, 10);
  assert.equal(deep.isAdmin, undefined);
  assert.equal(shallow.isAdmin, undefined);
  assert.equal({}.isAdmin, undefined);
});
A hint

Loop over Object.entries(source), continue for __proto__ and for undefined, and recurse only when deep is true and the value is an array or a plain object (Object.getPrototypeOf(value) is Object.prototype or null). The recursive call needs its own target: the existing value when it has the same kind, else a new [] or {}.

The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

URL Encoder & Decoder Decode a form body from one of your own pages, to read its bracket names before you replace a $.param call.

Last, the lesson’s own questions, asked one by one with your weakest idea first; every choice, right or wrong, says why:

Practice that adapts to you

Practise this lesson

15 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 15 Inside a function callback of $.each([5], fn), outside strict mode, what is this?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: The value 5, wrapped in a Number object

    Name the value as a parameter instead of reading this, so the code works the same in strict mode and in an arrow function.

  2. Question 2 of 15 Which native loop gives the same pairs as $.each(stock, (key, value) => …) for stock = { kettle: 12, lamp: 0 }?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: for (const [key, value] of Object.entries(stock))

    For arrays use entries() of the array, and for objects Object.entries(); both give [key, value] pairs to for...of.

  3. Question 3 of 15 A $.each loop searches for the first order over 10,000 and returns false when it finds it. What is the clearest native replacement?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: orders.find((o) => o.total > 10000)

    A loop that stops early is often a search: find(), some() or findIndex() say so in one call.

  4. Question 4 of 15 What does $.map(orders, o => o.note) return when no order has a note?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: An empty array

    A callback that returns nothing removes the item: a $.map can filter and map in one pass, which map() cannot.

  5. Question 5 of 15 What does $.grep([12, 0, 5, 0], n => n > 0, true) return?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: [0, 0]

    The native form is filter() with the test negated: counts.filter(n => !(n > 0)).

  6. Question 6 of 15 Old code reads $.map(rows, function (index, row) { return row.id; }) and returns undefined for every row. Why?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: $.map passes the value first, so the parameter named index holds the row and row holds the index

    The names in the callback are the only clue to the order, so check each translation against the table of argument orders.

  7. Question 7 of 15 What does $.inArray('cheque', ["card", "cash", "wallet"]) return?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: -1

    -1 is truthy, which is why if ($.inArray(…)) treats a missing value as found.

  8. Question 8 of 15 Which of these finds NaN in [NaN]?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: [NaN].includes(NaN)

    Both compare strictly, so neither finds '5' in [5]; only includes() treats NaN as equal to itself.

  9. Question 9 of 15 copy = $.extend({}, base), then copy.labels.empty = "Changed". What does base.labels.empty say?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: "Changed", because the shallow copy shares base.labels

    $.extend(true, {}, base) or structuredClone(base) copy every level, so the original stays as it was.

  10. Question 10 of 15 What does structuredClone() do with an object that has a function in it?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: It throws DataCloneError

    structuredClone() is for data: objects, arrays, Dates, Maps and the like. Settings with callbacks need a merge of your own.

  11. Question 11 of 15 A team replaces $.extend(true, {}, defaults, JSON.parse(saved)) with its own deep merge. Which rule must the merge keep that is easy to forget?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: Skip the key __proto__, or saved JSON can add properties to every object

    A merge that walks into the key __proto__ merges into Object.prototype itself; one comparison, key === '__proto__', prevents it.

  12. Question 12 of 15 What does $.param(order, true), the traditional form, write for items: ["SKU-7310", "SKU-0042"]?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: items=SKU-7310&items=SKU-0042

    PHP keeps only the last of repeated plain names, as decode.php showed with SKU-0042, so a PHP server needs the bracket form.

  13. Question 13 of 15 A team removing jQuery wants to keep its PHP endpoint unchanged. Which native code sends the same order data as $.post(url, order)?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: fetch(url, { method: "POST", body: bracketParams(order) }), with bracket names built as $.param builds them

    Changing the encoding changes the contract with the server: keep the brackets, or change the endpoint on purpose.

  14. Question 14 of 15 What happens when code calls $.isArray(list) on jQuery 4.0.0 without jQuery Migrate?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: It throws a TypeError: $.isArray is not a function

    Array.isArray(list) gives the same answer in every browser jQuery 4 supports.

  15. Question 15 of 15 Why load jQuery Migrate 4.0.2 while upgrading code that still calls $.trim and $.parseJSON?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: It brings each removed function back with a warning that names it, so the calls keep working while you find them

    The warnings are the work list: each one is a call to replace with its native form.

Interview questions

Warm-up (fresher to 2–5 years): how do you stop a $.each loop early, and what replaces it in native code?

Return false from the callback: $.each stops at once, and any other return value moves on to the next item, like continue. forEach() cannot stop, because it ignores what its callback returns, so the native loop is for...of with break, over array.entries() when the index is needed, or some() and find() when the loop searches for a first match. Mind the arguments in the translation: $.each passes the index first, while entries() gives [index, value] pairs and forEach() passes the value first.

Two harder questions from this module’s bank build on it: an order form whose PHP server lost its arrays, and a deep $.extend of saved settings that has to go. Try each one yourself before you read its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An order form was sent with $.post(url, order). After jQuery was replaced with fetch() and new URLSearchParams(order), the PHP back end receives items as "SKU-7310,SKU-0042" and address as "[object Object]". Explain what changed, and fix it without changing the server.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    Settings code builds its options with $.extend(true, {}, defaults, JSON.parse(localStorage.getItem('prefs'))). You are asked to remove jQuery from this module. What do you replace the call with, and which of its behaviours must the replacement keep, or change on purpose?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • $.each passes the index and then the value and stops when its callback returns false; the native loop is for...of with break, because forEach() cannot stop and skips holes.
  • $.map drops null and undefined and flattens one level, so flatMap(x => x ?? []) replaces it, and $.map and $.grep pass the value first where $.each passes the index first.
  • $.inArray returns a position, so test it with !== -1, or use includes(), which also finds NaN.
  • $.extend skips undefined where Object.assign() and spread copy it, its deep form merges arrays by index, and every merge of saved data must skip __proto__.
  • $.param writes the bracket names that PHP reads as arrays, while URLSearchParams turns arrays and objects into text, so a body with nested data needs its brackets built by hand.
  • jQuery 4 removed eleven utilities; most have one native replacement, but $.trim(null) and $.isNumeric('12') need a test of their own.

Terms in this lesson

array-like object
An object with a length and items numbered 0 to length - 1, such as a function's arguments, a NodeList or a jQuery object, that is not an array. $.each() and $.map() go through it by index.
deep copy
A copy in which nested objects and arrays are copied too, so changing the copy never changes the original. structuredClone() makes one of plain data; $.extend(true, {}, obj) makes one of plain objects and arrays.
prototype pollution
A flaw in which code that merges or copies data it did not write follows a key such as __proto__ into Object.prototype, so every object of the program inherits the added property. jQuery 3.4.0 and later skip that key in $.extend().
shallow copy
A new object whose properties hold the same values as the original's, so a nested object or array is shared, not copied: changing it through the copy changes the original. $.extend({}, obj), Object.assign() and spread make one.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.