JavaScript Modern Syntax: Optional Chaining, Nullish Coalescing, Logical Assignment, and More

Modern JavaScript includes several syntax features that make code shorter, safer, and easier to maintain. These features help developers access nested object properties, handle missing values, update variables efficiently, write readable numbers, protect class data, and use asynchronous operations at the top level of a module.

In this tutorial, you will learn six important modern JavaScript features: optional chaining (?.), nullish coalescing (??), logical assignment operators, numeric separators, private class fields, and top-level await.

What Is Optional Chaining in JavaScript?

Optional chaining (?.) allows you to access a property, call a method, or access an array element without throwing an error when the value immediately before ?. is null or undefined.

Without optional chaining, accessing a property on a missing object can cause a TypeError.

Accessing Nested Object Properties

Consider this object:

const user = {
  name: "Karim",
  address: {
    city: "Hyderabad"
  }
};

console.log(user.address.city);

Output:

Hyderabad

The property exists, so JavaScript can access it normally.

However, if the address property is missing, accessing user.address.city can cause an error.

const user = {
  name: "Karim"
};

console.log(user.address.city);

This code throws a TypeError because user.address is undefined.

Using Optional Chaining

You can safely access the nested property with ?..

const user = {
  name: "Karim"
};

console.log(user.address?.city);

Output:

undefined

The expression stops when user.address is null or undefined and returns undefined instead of throwing an error.

Optional Chaining With Multiple Levels

You can use optional chaining at multiple levels of a nested object.

const user = {
  name: "Karim"
};

console.log(user.address?.city);
console.log(user.address?.location?.country);

Output:

undefined
undefined

Each optional chain protects the access that follows it. If user itself might be missing, you must also protect that earlier access, such as user?.address?.city.

Optional Chaining With Arrays

Optional chaining also works with array access.

const users = [
  { name: "Karim" },
  { name: "Ayesha" }
];

console.log(users[0]?.name);
console.log(users[5]?.name);

Output:

Karim
undefined

The second expression returns undefined because users[5] does not exist.

Optional Chaining With Functions

You can use ?.() to call a function only if the value is not null or undefined.

const user = {
  name: "Karim",
  greet() {
    return "Hello, Karim!";
  }
};

console.log(user.greet?.());
console.log(user.sayGoodbye?.());

Output:

Hello, Karim!
undefined

The optional call prevents an error when sayGoodbye is missing.

Keep in mind that optional chaining does not silently ignore every kind of problem. If a property exists but contains a non-function value, an optional call can still throw a TypeError.

When Should You Use Optional Chaining?

Optional chaining is especially useful when working with:

  • API responses that may contain missing properties.
  • User profile information.
  • Nested configuration objects.
  • Optional callback functions.
  • Array elements that may not exist.

It does not validate data or replace proper error handling. Use it when missing values are an expected possibility.

What Is Nullish Coalescing in JavaScript?

The nullish coalescing operator (??) provides a fallback value when the expression on its left is null or undefined.

It is useful when a value may be missing but other falsy values, such as 0, false, and an empty string, should be preserved.

Basic Example of Nullish Coalescing

const userName = null;

const displayName = userName ?? "Guest";

console.log(displayName);

Output:

Guest

Because userName is null, JavaScript uses the fallback value "Guest".

Preserving Zero Values

Consider a website that displays the number of notifications a user has.

const notificationCount = 0;

const count = notificationCount ?? 10;

console.log(count);

Output:

0

The value 0 is not nullish, so JavaScript keeps it instead of using 10.

This is an important difference between ?? and the logical OR operator (||).

Nullish Coalescing vs Logical OR

The logical OR operator uses the fallback when the left-hand value is falsy. Nullish coalescing uses the fallback only when the value is null or undefined.

| Value | value || "Default" | value ?? "Default" |
|—|—|—|
| null | "Default" | "Default" |
| undefined | "Default" | "Default" |
| 0 | "Default" | 0 |
| false | "Default" | false |
| "" | "Default" | "" |
| "JavaScript" | "JavaScript" | "JavaScript" |

Example:

const price = 0;

console.log(price || 100);
console.log(price ?? 100);

Output:

100
0

Use ?? when 0, false, or "" may be valid values. Use || when any falsy value should trigger the fallback.

Using Nullish Coalescing With Optional Chaining

These operators work well together.

const user = {
  name: "Karim"
};

const city = user.address?.city ?? "Unknown city";

console.log(city);

Output:

Unknown city

Optional chaining safely accesses the missing property, and nullish coalescing supplies a fallback.

What Are Logical Assignment Operators in JavaScript?

Logical assignment operators combine a logical operation with assignment. They allow you to update a variable based on its current value using concise syntax.

JavaScript provides three logical assignment operators:

  • ||= — logical OR assignment
  • &&= — logical AND assignment
  • ??= — nullish coalescing assignment

Logical OR Assignment (||=)

The ||= operator assigns a new value only when the existing value is falsy.

let userName = "";

userName ||= "Guest";

console.log(userName);

Output:

Guest

Because an empty string is falsy, the fallback value is assigned.

This is conceptually similar to:

if (!userName) {
  userName = "Guest";
}

However, logical assignment operators evaluate the left-hand side only once, which can be important when it involves a property or other expression with side effects.

Logical AND Assignment (&&=)

The &&= operator assigns a new value only when the existing value is truthy.

let isLoggedIn = true;

isLoggedIn &&= "Welcome back!";

console.log(isLoggedIn);

Output:

Welcome back!

Since isLoggedIn is truthy, the new value is assigned.

If the original value is falsy, the assignment does not happen.

let isLoggedIn = false;

isLoggedIn &&= "Welcome back!";

console.log(isLoggedIn);

Output:

false

Nullish Coalescing Assignment (??=)

The ??= operator assigns a new value only when the existing value is null or undefined.

let userAge;

userAge ??= 18;

console.log(userAge);

Output:

18

Because userAge is undefined, JavaScript assigns 18.

Compare that with a value of zero:

let userAge = 0;

userAge ??= 18;

console.log(userAge);

Output:

0

The value remains 0 because it is not nullish.

Comparing Logical Assignment Operators

OperatorAssigns when the current value isExample
`=`Falsy`name= “Guest”`
&&=Truthyactive &&= false
??=null or undefinedage ??= 18

These operators can simplify conditional assignments, but make sure the chosen operator matches the behavior your application needs.

What Are Numeric Separators in JavaScript?

Numeric separators allow you to use underscores (_) inside numeric literals to make long numbers easier to read.

They do not change the numeric value. They only improve readability.

Using Numeric Separators With Large Numbers

Without separators, large numbers can be difficult to read.

const population = 1000000000;

console.log(population);

Output:

1000000000

With numeric separators:

const population = 1_000_000_000;

console.log(population);

Output:

1000000000

Both examples produce the same number, but the second is easier to scan.

Numeric Separators With Decimal Numbers

You can also use separators in the integer or fractional portions of a decimal number.

const price = 12_500.75;
const measurement = 3.141_592;

console.log(price);
console.log(measurement);

Output:

12500.75
3.141592

Numeric Separators With Binary and Hexadecimal Numbers

Numeric separators work with several numeric literal formats.

const binaryValue = 0b1010_0001;
const hexadecimalValue = 0xFF_EC_DE_5E;

console.log(binaryValue);
console.log(hexadecimalValue);

Output:

161
4293713502

The separators make groups of digits easier to identify.

Rules for Numeric Separators

Follow these rules when writing numeric literals:

  • Place underscores between digits.
  • Do not put an underscore at the beginning or end of a number.
  • Do not put an underscore immediately before or after a decimal point.
  • Do not put an underscore directly after a numeric prefix such as 0x or 0b.
  • Numeric separators are supported in numeric literals, not in ordinary string representations of numbers.

For example, 1_000_000 is valid, but 1__000, _1000, and 1000_ are invalid numeric literals.

What Are Private Class Fields in JavaScript?

Private class fields allow a class to define properties that can be accessed only from inside that class’s body. They use a hash symbol (#) before the field name.

Private fields are useful for encapsulation, which means keeping an object’s internal state separate from its public interface.

Creating a Private Field

class BankAccount {
  #balance = 0;

  deposit(amount) {
    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount();

account.deposit(500);

console.log(account.getBalance());

Output:

500

The field #balance is private. Code outside the class cannot directly access it.

Trying to Access a Private Field

The following example throws a syntax error because the private field is accessed outside the class definition.

class BankAccount {
  #balance = 500;
}

const account = new BankAccount();

console.log(account.#balance);

This code is invalid. Private field access is restricted by the language, not merely by naming conventions.

For comparison, a property named _balance is only conventionally private; JavaScript does not enforce privacy for it.

Using Private Fields to Protect Internal State

A class can expose methods that control how its internal data changes.

class BankAccount {
  #balance = 0;

  deposit(amount) {
    if (amount <= 0) {
      throw new Error("Deposit must be greater than zero.");
    }

    this.#balance += amount;
  }

  getBalance() {
    return this.#balance;
  }
}

const account = new BankAccount();

account.deposit(1000);

console.log(account.getBalance());

Output:

1000

The class controls how deposits are made, and outside code cannot directly overwrite #balance.

This example illustrates encapsulation, but real financial systems also require server-side validation, secure transaction handling, and persistent records.

Private Methods

JavaScript also supports private methods using the # prefix.

class User {
  #formatName(name) {
    return name.trim().toUpperCase();
  }

  constructor(name) {
    this.name = this.#formatName(name);
  }
}

const user = new User("karim");

console.log(user.name);

Output:

KARIM

The #formatName() method can be called inside the class, but not directly from outside it.

Private fields and methods are particularly useful when a class needs to enforce rules about how its internal state is read or updated.

What Is Top-Level await in JavaScript?

The await keyword is commonly used inside async functions to wait for a Promise to settle. Top-level await allows you to use await directly in an ES module without wrapping it inside an async function.

This feature is useful when a module needs to load data or complete an asynchronous operation before continuing with its module evaluation.

Using await Inside an Async Function

Traditionally, you could write:

async function loadData() {
  const response = await fetch(
    "https://jsonplaceholder.typicode.com/todos/1"
  );

  const data = await response.json();

  console.log(data);
}

loadData();

The await expressions are inside an async function.

Using Top-Level await

In an ES module, you can write the same operation without defining an additional async function.

Create main.js:

const response = await fetch(
  "https://jsonplaceholder.typicode.com/todos/1"
);

const data = await response.json();

console.log(data);

Example output:

{
  userId: 1,
  id: 1,
  title: "delectus aut autem",
  completed: false
}

The exact formatting depends on the browser console.

For a browser project, load this file as a module:

<script type="module" src="./main.js"></script>

Top-level await works in ES modules, not in ordinary classic browser scripts or CommonJS files as a general top-level feature.

Top-Level await With Dynamic Imports

You can use top-level await with dynamic imports to load a module and immediately access its exports.

const module = await import("./message.js");

module.showMessage();

Assuming message.js exports a function named showMessage(), the module is loaded and the function is called after the import resolves.

This can make module initialization easier to write when an application requires a dependency before it can continue.

Top-Level await With Error Handling

Asynchronous operations can fail, so use appropriate error handling.

try {
  const response = await fetch(
    "https://jsonplaceholder.typicode.com/todos/1"
  );

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  const data = await response.json();

  console.log(data);
} catch (error) {
  console.error("Unable to load data:", error);
}

The try...catch block handles network errors and the error thrown when the response status is unsuccessful.

Checking response.ok is important because fetch() generally does not reject its Promise merely because the server returns an HTTP error status such as 404.

Important Considerations for Top-Level await

Top-level await can simplify module initialization, but it should be used thoughtfully.

  • It is supported in ES modules.
  • It allows a module to wait for an asynchronous operation before its evaluation completes.
  • Modules that depend on the waiting module may also need to wait.
  • Unnecessary top-level waiting can delay dependent modules.
  • Error handling remains important when loading remote data or dynamic modules.

Use it when the module genuinely needs an asynchronous result before it can initialize its functionality.

Quick Comparison of Modern JavaScript Syntax

FeatureMain purposeExample
Optional chainingSafely access potentially missing valuesuser.address?.city
Nullish coalescingProvide a fallback for nullish valuesname ?? "Guest"
Logical assignmentCombine a logical condition with assignmentcount ??= 0
Numeric separatorsImprove the readability of numeric literals1_000_000
Private class fieldsRestrict access to internal class data#balance = 0
Top-level awaitWait for asynchronous operations at module levelconst data = await response.json()

Frequently Asked Questions

What is the difference between ?. and ???

Optional chaining safely accesses a property or calls a method when the value before the operator is null or undefined. Nullish coalescing supplies a fallback when an expression evaluates to null or undefined.

For example, user.address?.city ?? "Unknown" safely accesses the city and provides a fallback if it is missing.

What is the difference between ?? and ||?

The ?? operator uses its fallback only for null and undefined. The || operator uses its fallback for any falsy value, including 0, false, and an empty string.

What does ??= mean in JavaScript?

The ??= operator assigns a fallback value only when the existing value is null or undefined.

Do numeric separators change a number’s value?

No. Numeric separators only improve readability. For example, 1_000_000 and 1000000 represent the same numeric value.

Are private class fields truly private?

Yes. JavaScript enforces private field access through the # syntax. Code outside the class cannot directly access those fields using ordinary property access.

Can top-level await be used in a normal JavaScript script?

No, not as a general top-level feature in a classic script. Top-level await is available in ES modules, such as JavaScript files loaded using <script type="module"> in browsers.

Which modern syntax features should beginners learn first?

Start with optional chaining and nullish coalescing because they help with everyday data handling. Then learn logical assignment and numeric separators. Private class fields and top-level await become especially useful when working with object-oriented code and module-based applications.

Conclusion

Modern JavaScript syntax makes everyday programming tasks easier and more readable.

Optional chaining prevents errors when accessing missing properties, nullish coalescing provides reliable fallback values, and logical assignment simplifies conditional updates. Numeric separators improve the readability of large numbers, private class fields support encapsulation, and top-level await simplifies asynchronous module initialization.

Learning these features will help you write cleaner JavaScript and understand the syntax used in modern web applications, libraries, and frameworks.