Working with nested objects and potentially null/undefined values is common in JavaScript, but traditionally required verbose null checks to avoid errors. Modern JavaScript introduces two elegant operators: optional chaining (?.) for safe property access and nullish coalescing (??) for smart default values. These operators dramatically simplify code while making it more robust. Let's master these powerful features!
Optional Chaining (?.)
The Problem
// Without optional chaining - verbose and error-prone
let user = {
name: 'Alice',
address: {
city: 'NYC',
zip: '10001'
}
};
// Accessing nested properties safely
let city;
if (user && user.address && user.address.city) {
city = user.address.city;
}
console.log(city); // "NYC"
// If user is null, this crashes!
let user2 = null;
// let city2 = user2.address.city; // TypeError: Cannot read property 'address' of null
// Need defensive checks everywhere
let city3;
if (user2 && user2.address && user2.address.city) {
city3 = user2.address.city;
}
console.log(city3); // undefined (no error)The Solution: Optional Chaining
// With optional chaining - clean and safe!
let user = {
name: 'Alice',
address: {
city: 'NYC',
zip: '10001'
}
};
// Safe property access
let city = user?.address?.city;
console.log(city); // "NYC"
// If any part is null/undefined, returns undefined (no error!)
let user2 = null;
let city2 = user2?.address?.city;
console.log(city2); // undefined (no crash!)
// Works with undefined too
let user3;
let city3 = user3?.address?.city;
console.log(city3); // undefined
// Short-circuits at first null/undefined
let user4 = { name: 'Bob' }; // No address property
let city4 = user4?.address?.city;
console.log(city4); // undefined (address is undefined)?. makes code cleaner and safer
Without Optional Chaining
// Old way: Manual null checks
let user = getUser();
let city;
if (user &&
user.address &&
user.address.city) {
city = user.address.city;
}
let street;
if (user &&
user.address &&
user.address.street) {
street = user.address.street;
}
// Deeply nested - even worse!
let value;
if (data &&
data.results &&
data.results[0] &&
data.results[0].user &&
data.results[0].user.profile) {
value = data.results[0].user.profile.name;
}With Optional Chaining
// Modern way: Optional chaining
let user = getUser();
let city = user?.address?.city;
let street = user?.address?.street;
// Clean, readable, safe!
// Deeply nested - much cleaner!
let value = data
?.results
?.[0]
?.user
?.profile
?.name;
// So much better!Optional Chaining Usage
1. Object Property Access
let user = {
name: 'Alice',
address: {
city: 'NYC'
}
};
// Normal property access
console.log(user?.name); // "Alice"
console.log(user?.address?.city); // "NYC"
console.log(user?.phone); // undefined
console.log(user?.address?.street); // undefined
// With null/undefined objects
let user2 = null;
console.log(user2?.name); // undefined (no error)
let user3 = undefined;
console.log(user3?.address?.city); // undefined (no error)2. Array Element Access
let users = [
{ name: 'Alice' },
{ name: 'Bob' },
{ name: 'Charlie' }
];
// Safe array access
console.log(users?.[0]?.name); // "Alice"
console.log(users?.[10]?.name); // undefined (index doesn't exist)
// With null/undefined arrays
let users2 = null;
console.log(users2?.[0]?.name); // undefined (no error)
// Dynamic index
let index = 1;
console.log(users?.[index]?.name); // "Bob"
// Nested arrays
let data = {
results: [
{ items: [1, 2, 3] },
{ items: [4, 5, 6] }
]
};
console.log(data?.results?.[0]?.items?.[1]); // 2
console.log(data?.results?.[5]?.items?.[0]); // undefined3. Function/Method Calls
let user = {
name: 'Alice',
greet: function() {
return 'Hello!';
}
};
// Safe method call
console.log(user.greet?.()); // "Hello!"
// If method doesn't exist
console.log(user.sayBye?.()); // undefined (no error)
// With null object
let user2 = null;
console.log(user2?.greet?.()); // undefined (no error)
// Practical example: Optional callback
function processData(data, callback) {
let result = transform(data);
// Call callback only if it exists
callback?.(result);
return result;
}
// With callback
processData(data, (result) => console.log(result));
// Without callback (no error)
processData(data);
// API example
let api = {
user: {
getData: async () => {
return { name: 'Alice' };
}
}
};
// Safe async method call
let data = await api?.user?.getData?.();
console.log(data); // { name: 'Alice' }4. Combining Optional Chaining
// Complex nested structure
let response = {
data: {
users: [
{
profile: {
getName: () => 'Alice'
}
}
]
}
};
// Chain property access, array access, and method call
let name = response
?.data
?.users
?.[0]
?.profile
?.getName
?.();
console.log(name); // "Alice"
// If any part is null/undefined, returns undefined
let response2 = { data: { users: [] } };
let name2 = response2?.data?.users?.[0]?.profile?.getName?.();
console.log(name2); // undefined (no crash!)Nullish Coalescing (??)
The Problem with OR (||)
// Problem: || treats ALL falsy values as falsy
// Falsy values: false, 0, '', null, undefined, NaN
let count = 0;
let value1 = count || 10;
console.log(value1); // 10 (but we wanted 0!)
let message = '';
let value2 = message || 'Default';
console.log(value2); // "Default" (but we wanted '')
let isActive = false;
let value3 = isActive || true;
console.log(value3); // true (but we wanted false!)
// || replaces ALL falsy values with default
// This is often NOT what we want!The Solution: Nullish Coalescing
// ?? only treats null and undefined as "nullish"
// It keeps other falsy values (0, '', false)
let count = 0;
let value1 = count ?? 10;
console.log(value1); // 0 (keeps 0!)
let message = '';
let value2 = message ?? 'Default';
console.log(value2); // '' (keeps empty string!)
let isActive = false;
let value3 = isActive ?? true;
console.log(value3); // false (keeps false!)
// Only replaces null/undefined
let value4 = null ?? 'Default';
console.log(value4); // "Default"
let value5 = undefined ?? 'Default';
console.log(value5); // "Default"
// Perfect for default values!
let settings = {
volume: 0, // 0 is valid
brightness: 50,
notifications: false // false is valid
};
let volume = settings.volume ?? 100;
console.log(volume); // 0 (not 100!)
let notifications = settings.notifications ?? true;
console.log(notifications); // false (not true!)?? is safer for defaults
|| Operator
// OR operator (||)
// Treats ALL falsy as falsy
let count = 0;
console.log(count || 10); // 10
let message = '';
console.log(message || 'Hi'); // "Hi"
let ready = false;
console.log(ready || true); // true
// Problem: Replaces valid values!
// Settings with defaults
let volume = settings.volume || 50;
// If volume is 0, uses 50 (wrong!)
let muted = settings.muted || false;
// If muted is false, uses false (ok by luck)?? Operator
// Nullish coalescing (??)
// Only null/undefined are nullish
let count = 0;
console.log(count ?? 10); // 0
let message = '';
console.log(message ?? 'Hi'); // ''
let ready = false;
console.log(ready ?? true); // false
// Keeps ALL valid values!
// Settings with defaults
let volume = settings.volume ?? 50;
// If volume is 0, keeps 0 (correct!)
let muted = settings.muted ?? false;
// Only uses false if undefinedCombining ?. and ??
// Powerful combination: optional chaining + nullish coalescing
let user = {
name: 'Alice',
settings: {
theme: 'dark',
notifications: false
}
};
// Get nested value with default
let theme = user?.settings?.theme ?? 'light';
console.log(theme); // "dark"
let language = user?.settings?.language ?? 'en';
console.log(language); // "en" (default, property doesn't exist)
// Handles null user gracefully
let user2 = null;
let theme2 = user2?.settings?.theme ?? 'light';
console.log(theme2); // "light" (user is null)
// With methods
let config = {
getTimeout: () => 0 // 0 is valid timeout
};
let timeout = config?.getTimeout?.() ?? 5000;
console.log(timeout); // 0 (not 5000!)
// If method doesn't exist
let config2 = {};
let timeout2 = config2?.getTimeout?.() ?? 5000;
console.log(timeout2); // 5000 (method undefined)
// Complex example
let api = {
users: [
{ name: 'Alice', age: 25 },
{ name: 'Bob' } // No age
]
};
let firstUserAge = api?.users?.[0]?.age ?? 0;
console.log(firstUserAge); // 25
let secondUserAge = api?.users?.[1]?.age ?? 0;
console.log(secondUserAge); // 0 (age is undefined)
let thirdUserAge = api?.users?.[2]?.age ?? 0;
console.log(thirdUserAge); // 0 (user doesn't exist)Practical Examples
Example 1: API Response Handling
// Handling API responses safely
async function getUserProfile(userId) {
try {
let response = await fetch(`/api/users/${userId}`);
let data = await response.json();
// Safe access with defaults
return {
name: data?.user?.name ?? 'Anonymous',
email: data?.user?.email ?? 'No email',
avatar: data?.user?.profile?.avatar ?? '/default-avatar.png',
bio: data?.user?.profile?.bio ?? '',
location: data?.user?.profile?.location?.city ?? 'Unknown',
posts: data?.user?.posts?.length ?? 0,
followers: data?.user?.stats?.followers ?? 0,
verified: data?.user?.verified ?? false
};
} catch (error) {
// Return defaults if fetch fails
return {
name: 'Anonymous',
email: 'No email',
avatar: '/default-avatar.png',
bio: '',
location: 'Unknown',
posts: 0,
followers: 0,
verified: false
};
}
}
// Usage
let profile = await getUserProfile(123);
console.log(profile.name); // Always has a valueExample 2: Configuration Management
// Managing app configuration with safe defaults
class Config {
constructor(userConfig) {
this.config = userConfig;
}
get(path, defaultValue) {
// Split path: "api.timeout" -> ["api", "timeout"]
let keys = path.split('.');
let value = this.config;
// Navigate path safely
for (let key of keys) {
value = value?.[key];
if (value === undefined) break;
}
// Return value or default
return value ?? defaultValue;
}
}
// User provides partial config
let userConfig = {
api: {
baseURL: 'https://api.example.com',
timeout: 0 // 0 is valid!
},
features: {
analytics: false // false is valid!
}
};
let config = new Config(userConfig);
console.log(config.get('api.baseURL', 'http://localhost')); // User value
console.log(config.get('api.timeout', 5000)); // 0 (not default!)
console.log(config.get('api.retries', 3)); // 3 (default)
console.log(config.get('features.analytics', true)); // false (not default!)
console.log(config.get('features.darkMode', true)); // true (default)
console.log(config.get('database.host', 'localhost')); // 'localhost' (default)Example 3: Form Data Extraction
// Safely extract form data with validation
function extractFormData(form) {
// Safely get form elements
let elements = form?.elements;
if (!elements) {
return null;
}
return {
// Text inputs (keep empty strings)
username: elements.username?.value ?? '',
email: elements.email?.value ?? '',
// Number inputs (keep 0)
age: parseInt(elements.age?.value) ?? null,
salary: parseFloat(elements.salary?.value) ?? 0,
// Checkboxes (keep false)
newsletter: elements.newsletter?.checked ?? false,
terms: elements.terms?.checked ?? false,
// Select dropdowns
country: elements.country?.value ?? 'US',
// Radio buttons
gender: elements.gender?.value ?? 'prefer-not-to-say',
// Optional fields
phone: elements.phone?.value ?? null,
website: elements.website?.value ?? null
};
}
// Usage
let form = document.getElementById('registrationForm');
let data = extractFormData(form);
console.log(data);
// {
// username: "alice",
// email: "alice@test.com",
// age: 25,
// salary: 0, // Keeps 0!
// newsletter: false, // Keeps false!
// terms: true,
// country: "US",
// gender: "female",
// phone: null,
// website: null
// }Example 4: Event Handler Safety
// Safe event handling with optional chaining
class EventManager {
constructor() {
this.listeners = {};
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
}
emit(event, data) {
// Safely call all listeners
this.listeners[event]?.forEach(callback => {
try {
callback?.(data);
} catch (error) {
console.error(`Error in ${event} listener:`, error);
}
});
}
off(event, callback) {
// Safely remove listener
let listeners = this.listeners[event];
if (!listeners) return;
let index = listeners.indexOf(callback);
if (index > -1) {
listeners.splice(index, 1);
}
}
}
// Usage
let events = new EventManager();
events.on('userLogin', (user) => {
console.log(`Welcome, ${user?.name ?? 'Guest'}!`);
});
events.on('userLogin', (user) => {
// This listener might throw
console.log(user.profile.avatar); // Might error
});
// Emit safely (second listener won't crash the first)
events.emit('userLogin', { name: 'Alice' });
// Emit with null data (handlers don't crash)
events.emit('userLogin', null);
// Emit event with no listeners (doesn't crash)
events.emit('nonExistentEvent', {});Optional Chaining & Nullish Coalescing
Master safe access and smart defaults
console.log() to see your output in the console above.Key Takeaways
?.(optional chaining) safely accesses nested properties- Returns
undefinedif any part is null/undefined - Works with properties, arrays, and function calls
??(nullish coalescing) provides default values- Only treats
nullandundefinedas nullish - Keeps
0,'', andfalse(unlike ||) - Combine
?.and??for safe access with defaults - Perfect for API responses and configuration
- Eliminates verbose null checking code
- Makes code cleaner, safer, and more maintainable
What's Next?
You now understand optional chaining and nullish coalescing—two powerful operators that make handling null/undefined values elegant and safe! You've learned when to use each and how to combine them for maximum effect.
In the final Modern JavaScript lesson, we'll explore Rest and Spread Operators—the versatile ... syntax for collecting and spreading values in arrays, objects, and function parameters!
💪 Practice Challenge:
Before moving on, try:
- Replace nested if checks with optional chaining
- Use ?? instead of || for default values where 0 or false are valid
- Combine ?. and ?? to safely extract API data with defaults
- Handle deeply nested optional properties in configuration
- Use ?. for safe method calls on potentially undefined objects