Skip to content
JavaScript Basics: Read Object Options With Reliable Defaults

JavaScript Basics: Read Object Options With Reliable Defaults

October 14, 2026

Optional settings look simple until a valid value is 0, false, or an empty string. Another common surprise is copying an object and accidentally changing a nested value in the original. This tutorial uses small local examples to make defaults and object copying predictable.

Prepare a scratch directory

Prerequisites are Linux, Bash, the mktemp, rm, and rmdir utilities, a text editor, and Node.js on your PATH. These examples were tested with Node.js v24.19.0 on Linux. They require no third-party packages, environment variables, network requests, or package manifest. Files ending in .mjs use ES modules, so they can import Node’s built-in assertion module directly.

In Bash, run:

node --version
start_dir="$PWD"
work_dir="$(mktemp -d)" || exit 1
cd -- "$work_dir" || exit 1

The tested runtime prints:

v24.19.0

Keep this terminal open. Save the following files as UTF-8 with your editor inside the temporary directory. Each script is independent and includes its own input data.

Destructure options and preserve intentional values

Destructuring extracts object properties into local variables. A destructuring default applies when the property’s value is undefined, including when the property is absent. It does not replace null. See MDN’s destructuring reference.

Save as read-display-options.mjs:

import assert from "node:assert/strict";

function readOptions(input) {
  const { title = "Untitled", retries = 3, enabled = true } = input ?? {};
  return { title, retries, enabled };
}

const options = readOptions({ title: "", retries: 0, enabled: false });
assert.deepEqual(options, { title: "", retries: 0, enabled: false });
assert.deepEqual(readOptions(), {
  title: "Untitled", retries: 3, enabled: true,
});
assert.deepEqual(readOptions(null), readOptions());
assert.equal(readOptions({ title: null }).title, null);
assert.equal(readOptions({ title: undefined }).title, "Untitled");
console.log(JSON.stringify(options));
console.log(`Missing title: ${readOptions().title}`);
console.log(`Null title: ${readOptions({ title: null }).title}`);

Run:

node read-display-options.mjs

Expected output:

{"title":"","retries":0,"enabled":false}
Missing title: Untitled
Null title: null

The expression input ?? {} protects destructuring when the entire argument is null or undefined. A default parameter such as input = {} would cover an omitted argument or undefined, but not an explicit null.

There are two separate choices here: which whole inputs count as absent, and which property values deserve defaults. If null should also trigger the title fallback, read the property and apply title ?? "Untitled" instead. Defaults do not validate types; this function expects an options object or a nullish value, not arbitrary untrusted input.

Choose between nullish and truthy defaults

?? falls back only for null or undefined. || falls back for every falsy value, including 0, false, and "". Choose based on what those values mean in your application. MDN explains nullish coalescing.

Optional chaining returns undefined when the value at its guarded step is nullish. Guard every level that might be missing. It works naturally with ?? to provide a final fallback. See MDN’s optional chaining reference.

Save as read-profile-label.mjs:

import assert from "node:assert/strict";

function profileLabel(profile) {
  return profile?.preferences?.label ?? "Guest";
}

const profile = { preferences: { label: "", refreshSeconds: 0 } };
const seconds = profile.preferences.refreshSeconds;
assert.equal(seconds ?? 30, 0);
assert.equal(seconds || 30, 30);
assert.equal(false ?? true, false);
assert.equal(false || true, true);
assert.equal(profileLabel(profile), "");
assert.equal(profileLabel({ preferences: null }), "Guest");
assert.equal(profileLabel({}), "Guest");
assert.equal(profileLabel(null), "Guest");
console.log(`Refresh with ??: ${seconds ?? 30}`);
console.log(`Refresh with ||: ${seconds || 30}`);
console.log(`Existing label: ${JSON.stringify(profileLabel(profile))}`);
console.log(`Missing profile: ${profileLabel(undefined)}`);

Run:

node read-profile-label.mjs

Expected output:

Refresh with ??: 0
Refresh with ||: 30
Existing label: ""
Missing profile: Guest

Optional chaining is not general error handling. The root variable must exist, and a present property might still have the wrong type. Keeping the chain continuous also matters: (profile?.preferences).label would try to read label from undefined when the profile is missing.

Copy the level you intend to edit

Object spread creates a shallow copy. Nested objects are still shared references. To update a nested setting without changing the original, copy the outer object and the nested object along the changed path. Later properties in an object literal replace earlier properties with the same key. See MDN’s spread syntax reference.

Save as copy-theme-settings.mjs:

import assert from "node:assert/strict";

const original = { name: "Reader", theme: { mode: "light", size: 16 } };
const shallow = { ...original };
const updated = {
  ...original,
  theme: { ...original.theme, mode: "dark" },
};

assert.notEqual(shallow, original);
assert.equal(shallow.theme, original.theme);
assert.notEqual(updated.theme, original.theme);
updated.theme.size = 18;
assert.equal(original.theme.size, 16);
assert.equal(original.theme.mode, "light");
console.log(`Original: ${JSON.stringify(original)}`);
console.log(`Updated: ${JSON.stringify(updated)}`);

Run:

node copy-theme-settings.mjs

Expected output:

Original: {"name":"Reader","theme":{"mode":"light","size":16}}
Updated: {"name":"Reader","theme":{"mode":"dark","size":18}}

This is a targeted copy, not a universal deep clone. If theme contained another nested object, that deeper object would still be shared unless you copied it too. The example assumes the known plain-object shape shown above.

Clean up

After running all three scripts, remove only these tutorial files:

rm -- read-display-options.mjs read-profile-label.mjs copy-theme-settings.mjs
cd -- "$start_dir" || exit 1
rmdir -- "$work_dir"
unset start_dir work_dir

Before choosing a default, ask whether zero, false, an empty string, and null are meaningful. Before changing a copy, ask which nested references it still shares. Those two checks prevent many subtle settings bugs.

Acknowledgements

This post was created with assistance from AI.

Last updated on