JavaScript ModernFeatured

JavaScript Nullish Coalescing (??): Handle null and undefined

Master the nullish coalescing operator (??) in JavaScript. Learn how it differs from OR (||), use cases, and best practices for handling null and undefined values.

By JavaScript Document Team
nullish-coalescinges2020operatorsnull-safetymodern-javascript

The nullish coalescing operator (??) is a logical operator that returns the right-hand operand when the left-hand operand is null or undefined, and otherwise returns the left-hand operand. Introduced in ES2020, it provides a more precise way to set default values compared to the logical OR (||) operator.

Understanding Nullish Coalescing

The key difference between ?? and || is that ?? only considers null and undefined as "nullish" values, while || considers all falsy values.

Basic Syntax and Examples

// Basic syntax: leftExpr ?? rightExpr

// Returns right side only for null/undefined
console.log(null ?? 'default'); // 'default'
console.log(undefined ?? 'default'); // 'default'

// Returns left side for all other values
console.log(0 ?? 'default'); // 0
console.log('' ?? 'default'); // ''
console.log(false ?? 'default'); // false
console.log(NaN ?? 'default'); // NaN

// Comparison with OR operator
console.log(null || 'default'); // 'default'
console.log(undefined || 'default'); // 'default'
console.log(0 || 'default'); // 'default' (different!)
console.log('' || 'default'); // 'default' (different!)
console.log(false || 'default'); // 'default' (different!)

// Real-world example
const config = {
  timeout: 0, // Valid value: 0 milliseconds
  retries: false, // Valid value: no retries
  message: '', // Valid value: empty message
  endpoint: null, // Invalid: needs default
};

// Using ?? preserves falsy but valid values
const settings = {
  timeout: config.timeout ?? 5000, // 0 (keeps the zero)
  retries: config.retries ?? true, // false (keeps false)
  message: config.message ?? 'Hello', // '' (keeps empty string)
  endpoint: config.endpoint ?? '/api', // '/api' (replaces null)
};

Nullish Coalescing vs OR Operator

Understanding when to use ?? versus || is crucial for correct default value handling.

Key Differences

// Problem with OR operator
function createUser(options) {
  return {
    name: options.name || 'Anonymous',
    age: options.age || 18,
    isActive: options.isActive || true,
    credits: options.credits || 100,
  };
}

// Problematic cases
const user1 = createUser({
  name: '',
  age: 0,
  isActive: false,
  credits: 0,
});
console.log(user1);
// { name: 'Anonymous', age: 18, isActive: true, credits: 100 }
// All falsy values were replaced!

// Solution with nullish coalescing
function createUserFixed(options) {
  return {
    name: options.name ?? 'Anonymous',
    age: options.age ?? 18,
    isActive: options.isActive ?? true,
    credits: options.credits ?? 100,
  };
}

const user2 = createUserFixed({
  name: '',
  age: 0,
  isActive: false,
  credits: 0,
});
console.log(user2);
// { name: '', age: 0, isActive: false, credits: 0 }
// Falsy values are preserved!

// When you actually want falsy replacement, use ||
const displayName = userInput || 'Guest'; // Replace empty strings
const port = envPort || 3000; // Replace 0 with default

Common Use Cases Comparison

// Configuration defaults
const config = {
  // Use ?? when 0, false, '' are valid values
  port: process.env.PORT ?? 3000,
  debug: process.env.DEBUG ?? false,
  timeout: userTimeout ?? 0,
  prefix: customPrefix ?? '',

  // Use || when you want to replace all falsy values
  apiKey: process.env.API_KEY || 'development-key',
  username: inputUsername || 'guest',
  theme: selectedTheme || 'light',
};

// Form field defaults
function getFormDefaults(savedData) {
  return {
    // ?? for fields where empty/0/false are valid
    email: savedData?.email ?? '',
    age: savedData?.age ?? 0,
    newsletter: savedData?.newsletter ?? false,

    // || for fields that need non-falsy values
    country: savedData?.country || 'USA',
    language: savedData?.language || 'en',
  };
}

// API response handling
function processApiResponse(response) {
  return {
    // Use ?? for nullable fields
    data: response.data ?? [],
    error: response.error ?? null,

    // Use || for ensuring truthy values
    status: response.status || 'unknown',
    message: response.message || 'No message',
  };
}

Combining with Optional Chaining

Nullish coalescing works perfectly with optional chaining for safe property access with defaults.

Safe Navigation with Defaults

// API response example
const apiResponse = {
  user: {
    profile: {
      name: 'John',
      settings: {
        theme: null,
        notifications: {
          email: false,
          sms: undefined,
        },
      },
    },
  },
};

// Combining ?. and ??
const theme = apiResponse?.user?.profile?.settings?.theme ?? 'default';
console.log(theme); // 'default' (null is replaced)

const emailNotif =
  apiResponse?.user?.profile?.settings?.notifications?.email ?? true;
console.log(emailNotif); // false (false is kept)

const smsNotif =
  apiResponse?.user?.profile?.settings?.notifications?.sms ?? true;
console.log(smsNotif); // true (undefined is replaced)

const push = apiResponse?.user?.profile?.settings?.notifications?.push ?? false;
console.log(push); // false (missing property replaced)

// Function with safe access
function getUserSetting(response, path, defaultValue) {
  const keys = path.split('.');
  let value = response;

  for (const key of keys) {
    value = value?.[key];
  }

  return value ?? defaultValue;
}

console.log(getUserSetting(apiResponse, 'user.profile.name', 'Guest')); // 'John'
console.log(getUserSetting(apiResponse, 'user.profile.age', 18)); // 18

Nested Object Defaults

// Building objects with nested defaults
function createConfig(userConfig) {
  return {
    server: {
      host: userConfig?.server?.host ?? 'localhost',
      port: userConfig?.server?.port ?? 3000,
      ssl: userConfig?.server?.ssl ?? false,
    },
    database: {
      host: userConfig?.database?.host ?? 'localhost',
      port: userConfig?.database?.port ?? 5432,
      name: userConfig?.database?.name ?? 'myapp',
      pool: {
        min: userConfig?.database?.pool?.min ?? 2,
        max: userConfig?.database?.pool?.max ?? 10,
        idle: userConfig?.database?.pool?.idle ?? 10000,
      },
    },
    features: {
      auth: userConfig?.features?.auth ?? true,
      logging: userConfig?.features?.logging ?? true,
      cache: userConfig?.features?.cache ?? false,
    },
  };
}

// Partial configuration
const config = createConfig({
  server: { port: 8080 },
  features: { cache: true },
});

// Array element access with defaults
const items = ['first', 'second'];
const third = items?.[2] ?? 'default third';
console.log(third); // 'default third'

// Method call results with defaults
const calculator = {
  divide: (a, b) => (b !== 0 ? a / b : null),
};

const result = calculator?.divide?.(10, 0) ?? 0;
console.log(result); // 0 (null is replaced)

Practical Applications

Function Parameter Defaults

// Traditional default parameters only work for undefined
function greet(name = 'Guest') {
  console.log(`Hello, ${name}!`);
}

greet(); // Hello, Guest!
greet(undefined); // Hello, Guest!
greet(null); // Hello, null! (not replaced)
greet(''); // Hello, ! (empty string)

// Using nullish coalescing for more control
function greetFixed(name) {
  const displayName = name ?? 'Guest';
  console.log(`Hello, ${displayName}!`);
}

greetFixed(); // Hello, Guest!
greetFixed(undefined); // Hello, Guest!
greetFixed(null); // Hello, Guest!
greetFixed(''); // Hello, ! (empty string kept)

// Options object with nullish defaults
function createConnection(options = {}) {
  const config = {
    host: options.host ?? 'localhost',
    port: options.port ?? 5432,
    ssl: options.ssl ?? false,
    timeout: options.timeout ?? 0, // 0 is valid
    retries: options.retries ?? 3,
    poolSize: options.poolSize ?? 10,
    debug: options.debug ?? false, // false is valid
  };

  return config;
}

// Flexible function parameters
function processData(data, options) {
  const { filter = null, sort = null, limit = null } = options ?? {};

  let result = data;

  if (filter ?? false) {
    // Only apply if not null/undefined
    result = result.filter(filter);
  }

  if (sort ?? false) {
    result = result.sort(sort);
  }

  const maxItems = limit ?? result.length;
  return result.slice(0, maxItems);
}

State Management

// React-style state updates with nullish coalescing
class StateManager {
  constructor(initialState = {}) {
    this.state = initialState;
  }

  setState(updates) {
    this.state = {
      ...this.state,
      ...updates,
    };
  }

  get(key, defaultValue = null) {
    return this.state[key] ?? defaultValue;
  }

  // Merge with nullish values preserved
  merge(newState) {
    Object.keys(newState).forEach((key) => {
      this.state[key] = newState[key] ?? this.state[key];
    });
  }
}

// Redux-style reducer with nullish coalescing
function userReducer(state = {}, action) {
  switch (action.type) {
    case 'UPDATE_USER':
      return {
        ...state,
        name: action.payload.name ?? state.name,
        email: action.payload.email ?? state.email,
        age: action.payload.age ?? state.age,
        isActive: action.payload.isActive ?? state.isActive,
      };

    case 'SET_PREFERENCES':
      return {
        ...state,
        preferences: {
          theme: action.payload.theme ?? state.preferences?.theme ?? 'light',
          language:
            action.payload.language ?? state.preferences?.language ?? 'en',
          notifications:
            action.payload.notifications ??
            state.preferences?.notifications ??
            true,
        },
      };

    default:
      return state;
  }
}

// Vuex-style store with nullish getters
const store = {
  state: {
    user: null,
    settings: {
      theme: null,
      autoSave: false,
    },
  },

  getters: {
    userName: (state) => state.user?.name ?? 'Guest',
    userEmail: (state) => state.user?.email ?? '',
    theme: (state) => state.settings?.theme ?? 'default',
    autoSave: (state) => state.settings?.autoSave ?? false,
  },
};

API and Data Processing

// API client with nullish defaults
class APIClient {
  constructor(config = {}) {
    this.baseURL = config.baseURL ?? 'http://localhost:3000';
    this.timeout = config.timeout ?? 30000;
    this.retries = config.retries ?? 0;
    this.headers = config.headers ?? {};
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const config = {
      method: options.method ?? 'GET',
      headers: {
        'Content-Type': 'application/json',
        ...this.headers,
        ...options.headers,
      },
      timeout: options.timeout ?? this.timeout,
      retries: options.retries ?? this.retries,
    };

    if (options.body !== null && options.body !== undefined) {
      config.body = JSON.stringify(options.body);
    }

    try {
      const response = await fetch(url, config);
      const data = await response.json();

      return {
        success: true,
        data: data ?? null,
        error: null,
      };
    } catch (error) {
      return {
        success: false,
        data: null,
        error: error.message ?? 'Unknown error',
      };
    }
  }
}

// Data transformation with nullish handling
function transformUserData(rawData) {
  return {
    id: rawData?.id ?? generateId(),
    name: rawData?.name ?? 'Unknown',
    email: rawData?.email ?? '',
    profile: {
      bio: rawData?.profile?.bio ?? '',
      avatar: rawData?.profile?.avatar ?? '/default-avatar.png',
      verified: rawData?.profile?.verified ?? false,
      followers: rawData?.profile?.followers ?? 0,
      following: rawData?.profile?.following ?? 0,
    },
    settings: {
      notifications: {
        email: rawData?.settings?.notifications?.email ?? true,
        push: rawData?.settings?.notifications?.push ?? false,
        sms: rawData?.settings?.notifications?.sms ?? false,
      },
      privacy: {
        profilePublic: rawData?.settings?.privacy?.profilePublic ?? true,
        showEmail: rawData?.settings?.privacy?.showEmail ?? false,
      },
    },
    metadata: {
      createdAt: rawData?.createdAt ?? new Date().toISOString(),
      updatedAt: rawData?.updatedAt ?? null,
      lastLogin: rawData?.lastLogin ?? null,
    },
  };
}

Form Handling and Validation

// Form field processing with nullish coalescing
function processFormData(formData) {
  return {
    // Text fields - empty string is valid
    firstName: formData.firstName ?? '',
    lastName: formData.lastName ?? '',
    email: formData.email ?? '',

    // Numeric fields - 0 is valid
    age: formData.age ?? null,
    quantity: formData.quantity ?? 1,
    price: formData.price ?? 0,

    // Boolean fields - false is valid
    subscribe: formData.subscribe ?? false,
    acceptTerms: formData.acceptTerms ?? false,

    // Optional fields with specific defaults
    country: formData.country ?? 'USA',
    language: formData.language ?? navigator.language ?? 'en',
    timezone:
      formData.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
  };
}

// Validation with nullish coalescing
function validateField(value, rules) {
  const errors = [];

  // Required check - only null/undefined are invalid
  if (rules.required && (value ?? null) === null) {
    errors.push('This field is required');
  }

  // Length validation - only check if value exists
  const length = value?.length ?? 0;
  if (rules.minLength && length < rules.minLength) {
    errors.push(`Minimum length is ${rules.minLength}`);
  }

  if (rules.maxLength && length > rules.maxLength) {
    errors.push(`Maximum length is ${rules.maxLength}`);
  }

  // Numeric validation
  const numValue = Number(value) ?? NaN;
  if (rules.min !== undefined && numValue < rules.min) {
    errors.push(`Minimum value is ${rules.min}`);
  }

  if (rules.max !== undefined && numValue > rules.max) {
    errors.push(`Maximum value is ${rules.max}`);
  }

  return errors;
}

// Form state manager
class FormState {
  constructor(initialValues = {}) {
    this.values = initialValues;
    this.errors = {};
    this.touched = {};
  }

  getValue(field) {
    return this.values[field] ?? '';
  }

  setValue(field, value) {
    this.values[field] = value;
    this.touched[field] = true;
  }

  getError(field) {
    return this.touched[field] ? (this.errors[field] ?? null) : null;
  }

  reset(newValues = {}) {
    this.values = newValues;
    this.errors = {};
    this.touched = {};
  }
}

Assignment Operators

ES2021 introduced the nullish coalescing assignment operator (??=).

Nullish Coalescing Assignment

// Basic syntax: x ??= y
// Equivalent to: x = x ?? y

let a = null;
let b = undefined;
let c = 0;
let d = '';
let e = false;
let f = 'existing';

a ??= 'default'; // a = 'default'
b ??= 'default'; // b = 'default'
c ??= 'default'; // c = 0 (unchanged)
d ??= 'default'; // d = '' (unchanged)
e ??= 'default'; // e = false (unchanged)
f ??= 'default'; // f = 'existing' (unchanged)

// Object property initialization
const config = {
  timeout: 0,
  retries: null,
};

config.timeout ??= 5000; // remains 0
config.retries ??= 3; // becomes 3
config.maxSize ??= 1024; // adds property with 1024

// Conditional initialization
function initializeUser(user) {
  user.id ??= generateId();
  user.createdAt ??= new Date();
  user.preferences ??= {};
  user.preferences.theme ??= 'light';
  user.preferences.language ??= 'en';

  return user;
}

// Lazy initialization
class LazyLoader {
  load() {
    this._data ??= expensiveOperation();
    return this._data;
  }

  get data() {
    this._cache ??= this.load();
    return this._cache;
  }
}

// Setting defaults in loops
const items = [
  { name: 'Item 1', price: null },
  { name: 'Item 2', price: 0 },
  { name: 'Item 3' },
];

items.forEach((item) => {
  item.price ??= 9.99;
  item.quantity ??= 1;
  item.inStock ??= true;
});

Comparison with Other Assignment Operators

let x;

// OR assignment (||=)
x = 0;
x ||= 10; // x = 10 (replaces falsy)

x = 0;
x ??= 10; // x = 0 (keeps falsy non-nullish)

// AND assignment (&&=)
x = 5;
x &&= 10; // x = 10 (assigns if truthy)

x = 0;
x &&= 10; // x = 0 (doesn't assign if falsy)

// Practical example
const user = {
  name: '',
  email: null,
  age: 0,
  isActive: false,
};

// Different behaviors
user.name ||= 'Anonymous'; // 'Anonymous' (replaces empty string)
user.email ??= 'no@email.com'; // 'no@email.com' (replaces null)
user.age ||= 18; // 18 (replaces 0)
user.isActive ??= true; // false (keeps false)

Common Patterns and Best Practices

Configuration Objects

// Application configuration with nullish defaults
class AppConfig {
  constructor(userConfig = {}) {
    // Server settings
    this.port = userConfig.port ?? process.env.PORT ?? 3000;
    this.host = userConfig.host ?? process.env.HOST ?? 'localhost';

    // Database settings
    this.dbHost = userConfig.dbHost ?? process.env.DB_HOST ?? 'localhost';
    this.dbPort = userConfig.dbPort ?? process.env.DB_PORT ?? 5432;
    this.dbName = userConfig.dbName ?? process.env.DB_NAME ?? 'myapp';

    // Feature flags
    this.enableAuth = userConfig.enableAuth ?? true;
    this.enableCache = userConfig.enableCache ?? false;
    this.debugMode =
      userConfig.debugMode ?? process.env.NODE_ENV === 'development';

    // Limits and thresholds
    this.maxConnections = userConfig.maxConnections ?? 100;
    this.timeout = userConfig.timeout ?? 30000;
    this.retryLimit = userConfig.retryLimit ?? 0;
  }
}

// Environment variable parsing
function getEnvValue(key, defaultValue) {
  const value = process.env[key];

  // Parse different types
  if (value === 'true') return true;
  if (value === 'false') return false;
  if (value === 'null') return null;
  if (value === 'undefined') return undefined;
  if (!isNaN(value) && value !== '') return Number(value);

  return value ?? defaultValue;
}

Data Transformation

// Safe data mapping with nullish coalescing
function mapApiResponse(data) {
  return {
    users:
      data?.users?.map((user) => ({
        id: user.id ?? generateId(),
        name: user.name ?? 'Unknown User',
        email: user.email ?? '',
        avatar: user.avatar ?? '/default-avatar.png',
        role: user.role ?? 'user',
        isActive: user.isActive ?? true,
        metadata: {
          createdAt: user.createdAt ?? new Date().toISOString(),
          lastLogin: user.lastLogin ?? null,
          loginCount: user.loginCount ?? 0,
        },
      })) ?? [],

    pagination: {
      page: data?.page ?? 1,
      perPage: data?.perPage ?? 20,
      total: data?.total ?? 0,
      hasMore: data?.hasMore ?? false,
    },

    filters: {
      search: data?.filters?.search ?? '',
      status: data?.filters?.status ?? 'all',
      sortBy: data?.filters?.sortBy ?? 'createdAt',
      sortOrder: data?.filters?.sortOrder ?? 'desc',
    },
  };
}

// Chain of fallbacks
function getDisplayValue(data, userPreference, systemDefault) {
  return (
    data?.customValue ??
    userPreference?.value ??
    systemDefault?.value ??
    'fallback'
  );
}

Error Handling

// Error response handling
function handleApiError(error) {
  return {
    success: false,
    error: {
      message:
        error?.response?.data?.message ??
        error?.message ??
        'An unknown error occurred',
      code: error?.response?.data?.code ?? error?.code ?? 'UNKNOWN_ERROR',
      status: error?.response?.status ?? 500,
      details: error?.response?.data?.details ?? null,
    },
  };
}

// Safe error logging
function logError(error, context = {}) {
  console.error({
    message: error?.message ?? 'Unknown error',
    stack: error?.stack ?? 'No stack trace',
    code: error?.code ?? 'NO_CODE',
    timestamp: new Date().toISOString(),
    context: {
      user: context.user ?? 'anonymous',
      action: context.action ?? 'unknown',
      metadata: context.metadata ?? {},
    },
  });
}

Performance Considerations

// Nullish coalescing is generally as fast as || operator
// but provides more precise semantics

// Avoid repeated nullish checks in hot paths
function processLargeArray(items) {
  // Less efficient
  return items.map((item) => ({
    id: item?.id ?? generateId(),
    name: item?.name ?? 'Unknown',
    value: item?.value ?? 0,
  }));

  // More efficient - generate defaults once
  const defaults = {
    id: () => generateId(),
    name: 'Unknown',
    value: 0,
  };

  return items.map((item) => ({
    id: item?.id ?? defaults.id(),
    name: item?.name ?? defaults.name,
    value: item?.value ?? defaults.value,
  }));
}

// Cache nullish coalescing results
class CachedConfig {
  constructor(source) {
    this.source = source;
    this.cache = new Map();
  }

  get(key, defaultValue) {
    if (this.cache.has(key)) {
      return this.cache.get(key);
    }

    const value = this.source[key] ?? defaultValue;
    this.cache.set(key, value);
    return value;
  }
}

Common Pitfalls

String and Number Coercion

// Be careful with type coercion
const input = '0'; // String '0'

// Different results
console.log(input || 'default'); // '0' (truthy string)
console.log(input ?? 'default'); // '0'
console.log(+input || 'default'); // 'default' (0 is falsy)
console.log(+input ?? 'default'); // 0

// Explicit parsing with nullish coalescing
function parseNumber(value, defaultValue = 0) {
  const parsed = Number(value);
  return isNaN(parsed) ? defaultValue : (parsed ?? defaultValue);
}

Operator Precedence

// Nullish coalescing has lower precedence than most operators
console.log(3 + 4 ?? 0); // 7 (not 3 + 4)
console.log(3 + 4 ?? 0); // 7
console.log(3 + (4 ?? 0)); // 7

// Cannot mix with && or || without parentheses
// console.log(a || b ?? c);    // SyntaxError
// console.log(a ?? b && c);    // SyntaxError

// Use parentheses for clarity
console.log((a || b) ?? c); // OK
console.log(a ?? (b && c)); // OK

// Complex expressions
const value = (user?.settings?.theme ?? defaultTheme) || 'light';

Browser and Environment Support

// Check for nullish coalescing support
const supportsNullishCoalescing = (() => {
  try {
    eval('null ?? 1');
    return true;
  } catch {
    return false;
  }
})();

// Polyfill alternative (not a true polyfill)
function nullishCoalesce(value, defaultValue) {
  return value !== null && value !== undefined ? value : defaultValue;
}

// Babel/TypeScript will transpile ?? to:
// value !== null && value !== void 0 ? value : defaultValue

// Usage with fallback
const getValue = supportsNullishCoalescing ? (a, b) => a ?? b : nullishCoalesce;

Conclusion

The nullish coalescing operator (??) is an essential addition to JavaScript that solves the long-standing issue of distinguishing between nullish values (null/undefined) and other falsy values. It enables more precise default value assignment and works seamlessly with optional chaining for safe property access. Understanding when to use ?? versus || is crucial for writing robust JavaScript code that correctly handles edge cases with zeros, empty strings, and boolean false values. As part of modern JavaScript, it significantly improves code clarity and reduces bugs related to default value handling.