MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

TypeScript 3.7: Optional Chaining and Nullish Coalescing

Explore TypeScript 3.7's game-changing features: optional chaining, nullish coalescing, assertion functions, and more.

TypeScriptJavaScriptES2020

By MinhVo

Introduction

TypeScript 3.7, released in November 2019, was one of the most anticipated TypeScript releases in the language's history. It introduced two features that developers had been requesting for yearsβ€”optional chaining (?.) and nullish coalescing (??)β€”along with several other significant improvements including assertion functions, recursive type aliases, and smarter type narrowing. These features didn't just add syntactic sugar; they fundamentally changed how developers write null-safe code in TypeScript.

The timing was significant. The TC39 proposal for optional chaining had reached Stage 3, meaning it was on track to become part of the JavaScript standard. TypeScript 3.7 implemented these proposals ahead of their official inclusion in ES2020, allowing developers to use them immediately with the confidence that their code would be forward-compatible.

This guide covers every major feature in TypeScript 3.7, with practical examples, real-world patterns, and the subtle behaviors you need to understand to use them effectively.

TypeScript development

Understanding Optional Chaining

The Problem Optional Chaining Solves

Before TypeScript 3.7, accessing deeply nested properties required verbose null checks or defensive coding patterns:

// Before TypeScript 3.7
interface User {
  name: string;
  address?: {
    street?: string;
    city?: string;
    geo?: {
      lat: number;
      lng: number;
    };
  };
  company?: {
    name: string;
    department?: string;
  };
}
 
// Verbose null checking
let city: string | undefined;
if (user && user.address && user.address.city) {
  city = user.address.city;
}
 
// Or using the && operator (still verbose)
const city = user && user.address && user.address.city;
 
// Or using type assertions (unsafe!)
const city = (user as any).address.city; // Dangerous

Optional Chaining Syntax

Optional chaining provides a concise way to access deeply nested properties while automatically short-circuiting when a null or undefined value is encountered:

// After TypeScript 3.7
const city = user?.address?.city;
const lat = user?.address?.geo?.lat;
const dept = user?.company?.department;

The ?. operator works similarly to the . operator, but instead of throwing an error when the value before it is null or undefined, it returns undefined.

Optional Chaining Patterns

Optional chaining works with four different access patterns:

// 1. Optional property access
const name = user?.name;
 
// 2. Optional element access
const firstItem = arr?.[0];
const value = obj?.[key];
 
// 3. Optional call
const result = callback?.();
const length = str?.trim?.().length;
 
// 4. Optional chaining with method calls
const upperName = user?.getName?.()?.toUpperCase();

Short-Circuit Behavior

The key behavior of optional chaining is short-circuit evaluation. When the left-hand side is null or undefined, the right-hand side is never evaluated:

let value: string | undefined;
let counter = 0;
 
const result = value?.[counter++];
 
console.log(result);  // undefined
console.log(counter); // 0 β€” counter++ was never executed
 
// This prevents side effects from executing when they shouldn't
const data = config?.database?.connect?.();
// If config is null, connect() is never called

Optional Chaining with Function Calls

Optional chaining with function calls is particularly powerful but requires careful consideration:

// Basic optional call
const result = obj?.method?.();
 
// But be careful with arguments
// This is NOT the same as:
const result = obj?.method(arg);  // Calls obj.method(arg) if obj exists
// vs
const result = obj?.method?.(arg); // Calls obj.method(arg) if obj AND obj.method exist
 
// Practical example: event handler
function handleEvent(handler?: (event: Event) => void, event?: Event) {
  handler?.(event!); // Only calls handler if it exists
}
 
// With return value checking
interface ApiResponse {
  data?: {
    users?: Array<{ name: string }>;
  };
}
 
function getFirstUser(response: ApiResponse): string | undefined {
  return response.data?.users?.[0]?.name;
}

Optional Chaining in Assignments

Note that optional chaining cannot be used on the left side of an assignment:

// This is a syntax error
user?.address?.city = 'New York'; // ❌ Error
 
// Instead, use conditional assignment
if (user?.address) {
  user.address.city = 'New York'; // βœ… Works
}
 
// Or use the logical assignment operator (ES2021)
user ??= { name: 'Default' };

JavaScript evolution

Understanding Nullish Coalescing

The Problem with the || Operator

JavaScript's logical OR (||) operator has a long-standing quirk: it treats all falsy values the same way. This includes 0, "", NaN, false, null, and undefined:

// The problem with ||
const count = someValue || 10;
// If someValue is 0, count becomes 10 (probably not what you want)
 
const name = user.name || 'Anonymous';
// If user.name is "", name becomes 'Anonymous' (might not be desired)
 
const config = {
  timeout: 0,        // Valid value meaning "no timeout"
  retries: 0,        // Valid value meaning "no retries"
  name: '',          // Valid value meaning "empty name"
  enabled: false,    // Valid value meaning "disabled"
};
 
// These all "fail" with ||
const timeout = config.timeout || 5000;    // 5000 (wrong, 0 was intended)
const retries = config.retries || 3;       // 3 (wrong, 0 was intended)
const name = config.name || 'default';     // 'default' (wrong, "" was intended)
const enabled = config.enabled || true;    // true (wrong, false was intended)

Nullish Coalescing Syntax

The ?? operator is designed to handle exactly this case. It only falls back to the right-hand side when the left-hand side is null or undefined:

// With ?? operator
const count = someValue ?? 10;
// If someValue is 0, count is 0 βœ…
// If someValue is null, count is 10 βœ…
// If someValue is undefined, count is 10 βœ…
 
const timeout = config.timeout ?? 5000;    // 0 (correct!)
const retries = config.retries ?? 3;       // 0 (correct!)
const name = config.name ?? 'default';     // '' (correct!)
const enabled = config.enabled ?? true;    // false (correct!)

Nullish Coalescing vs Logical OR

The key distinction is what they consider "nullish":

// || checks for "falsy" values
// null, undefined, 0, '', NaN, false
 
// ?? checks for "nullish" values
// null, undefined (ONLY these two)
 
// Comparison table
console.log(0 || 'default');      // 'default'
console.log(0 ?? 'default');      // 0
 
console.log('' || 'default');     // 'default'
console.log('' ?? 'default');     // ''
 
console.log(false || 'default');  // 'default'
console.log(false ?? 'default');  // false
 
console.log(null || 'default');   // 'default'
console.log(null ?? 'default');   // 'default'
 
console.log(undefined || 'default'); // 'default'
console.log(undefined ?? 'default'); // 'default'

Short-Circuit Behavior

Like ||, the ?? operator short-circuitsβ€”its right-hand side is only evaluated when the left-hand side is null or undefined:

function getExpensiveDefault(): string {
  console.log('Computing default...');
  return 'computed default';
}
 
const value = someValue ?? getExpensiveDefault();
// getExpensiveDefault() is only called if someValue is null/undefined

Combining ?? with || and &&

TypeScript 3.7 introduced a syntax restriction when combining ?? with || or &&:

// ❌ This is a syntax error (requires explicit parentheses)
const result = a || b ?? c;
const result = a ?? b && c;
 
// βœ… Must use explicit parentheses
const result = (a || b) ?? c;
const result = a || (b ?? c);
const result = a ?? (b && c);
const result = (a ?? b) && c;
 
// This restriction exists to prevent ambiguity about operator precedence

Assertion Functions

What Are Assertion Functions?

TypeScript 3.7 introduced assertion functionsβ€”functions that signal to the TypeScript compiler that execution should not continue if the function returns. They're a more flexible alternative to type guards:

// Assertion function syntax
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string, got ${typeof value}`);
  }
}
 
// Usage
function processInput(input: unknown) {
  assertIsString(input);
  // After this point, TypeScript knows input is a string
  console.log(input.toUpperCase()); // βœ… No type error
}

Assertion Functions vs Type Guards

Type guards narrow types using the is keyword, but they require the caller to use them in conditional expressions. Assertion functions throw errors when the condition isn't met, which changes the control flow:

// Type guard (doesn't throw)
function isString(value: unknown): value is string {
  return typeof value === 'string';
}
 
// Caller must use conditional
if (isString(input)) {
  input.toUpperCase(); // βœ…
} else {
  // Handle non-string case
}
 
// Assertion function (throws on failure)
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error('Not a string');
  }
}
 
// Caller can proceed directly after assertion
assertIsString(input);
input.toUpperCase(); // βœ… TypeScript knows it's a string

Practical Assertion Function Patterns

// Asserting specific values
function assertDefined<T>(value: T | null | undefined, name: string): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(`${name} is required but got ${value}`);
  }
}
 
// Asserting object shapes
function assertHasProperty<T, K extends string>(
  obj: T,
  prop: K
): asserts obj is T & Record<K, unknown> {
  if (typeof obj !== 'object' || obj === null || !(prop in obj)) {
    throw new Error(`Expected object with property '${prop}'`);
  }
}
 
// Asserting array types
function assertIsNonEmpty<T>(arr: T[]): asserts arr is [T, ...T[]] {
  if (arr.length === 0) {
    throw new Error('Array must not be empty');
  }
}
 
// Usage
function processUser(data: unknown) {
  assertDefined(data, 'user data');
  assertHasProperty(data, 'name');
  assertHasProperty(data, 'email');
  
  // TypeScript now knows data has name and email properties
  console.log(data.name, data.email);
}

Assertion Functions with Control Flow

Assertion functions affect TypeScript's control flow analysis:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}
 
type Status = 'pending' | 'active' | 'completed';
 
function handleStatus(status: Status) {
  switch (status) {
    case 'pending':
      console.log('Pending...');
      break;
    case 'active':
      console.log('Active!');
      break;
    case 'completed':
      console.log('Done!');
      break;
    default:
      assertNever(status); // Ensures all cases handled
  }
}

Recursive Type Aliases

The Problem with Recursive Types

Before TypeScript 3.7, recursive type references were limitedβ€”you couldn't reference a type alias within its own definition in certain ways:

// Before 3.7: This was an error
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
// Error: Type alias 'Json' circularly references itself

Recursive Type Aliases in 3.7

TypeScript 3.7 allows direct recursive type references:

// TypeScript 3.7: Now works!
type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
 
// JSON value usage
const data: Json = {
  name: "John",
  age: 30,
  active: true,
  address: {
    street: "123 Main St",
    city: "Springfield",
    coordinates: {
      lat: 39.7817,
      lng: -89.6501,
    },
  },
  tags: ["admin", "user"],
  metadata: null,
};

Practical Recursive Type Patterns

// Tree structure
type TreeNode<T> = {
  value: T;
  children: TreeNode<T>[];
};
 
// Linked list
type LinkedList<T> = {
  value: T;
  next: LinkedList<T> | null;
};
 
// Nested menu
type MenuItem = {
  label: string;
  href?: string;
  icon?: string;
  children?: MenuItem[];
};
 
// Deeply nested configuration
type Config = {
  app: {
    name: string;
    version: string;
    settings: {
      theme: 'light' | 'dark';
      notifications: {
        email: boolean;
        push: boolean;
        frequency: 'instant' | 'daily' | 'weekly';
      };
    };
  };
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
};
 
// Type-safe deep partial
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

Smarter Type Narrowing

Control Flow Analysis Improvements

TypeScript 3.7 improved type narrowing in several scenarios:

// 1. Narrowing in callbacks
function processArray(arr: (string | number)[]) {
  arr.forEach((item) => {
    if (typeof item === 'string') {
      console.log(item.toUpperCase()); // βœ… TypeScript knows it's string
    } else {
      console.log(item.toFixed(2)); // βœ… TypeScript knows it's number
    }
  });
}
 
// 2. Narrowing with optional chaining
interface Result {
  value?: string | number;
}
 
function handleResult(result: Result) {
  if (typeof result.value === 'string') {
    // TypeScript narrows result.value to string here
    console.log(result.value.toUpperCase());
  }
}
 
// 3. Narrowing with assertion functions
function assertIsNumber(value: unknown): asserts value is number {
  if (typeof value !== 'number') throw new Error('Not a number');
}
 
function calculate(input: unknown) {
  assertIsNumber(input);
  return input * 2; // βœ… TypeScript knows input is number
}

Nullish Coalescing in Type Narrowing

// TypeScript narrows types based on ?? usage
function processConfig(config: { timeout?: number }) {
  const timeout = config.timeout ?? 5000;
  // timeout is number (not number | undefined)
  // Because ?? guarantees a non-nullish value
  
  console.log(timeout.toFixed(0)); // βœ… No type error
}

Modern development

Real-World Use Cases

Use Case 1: API Response Handling

Optional chaining and nullish coalescing are perfect for handling API responses:

interface ApiResponse {
  data?: {
    user?: {
      profile?: {
        name?: string;
        avatar?: string;
      };
      settings?: {
        theme?: string;
        notifications?: boolean;
      };
    };
    posts?: Array<{
      id: string;
      title: string;
      excerpt?: string;
    }>;
  };
  error?: {
    code: string;
    message: string;
  };
}
 
function renderUser(response: ApiResponse) {
  // Clean, safe property access
  const name = response.data?.user?.profile?.name ?? 'Anonymous';
  const avatar = response.data?.user?.profile?.avatar ?? '/default-avatar.png';
  const theme = response.data?.user?.settings?.theme ?? 'light';
  const notifications = response.data?.user?.settings?.notifications ?? true;
  const postCount = response.data?.posts?.length ?? 0;
  const firstPost = response.data?.posts?.[0]?.title ?? 'No posts yet';
  const errorMessage = response.error?.message;
  
  return { name, avatar, theme, notifications, postCount, firstPost, errorMessage };
}

Use Case 2: Configuration Merging

Nullish coalescing is ideal for configuration systems where you need to distinguish between "not set" and "explicitly set to falsy":

interface AppConfig {
  port?: number;
  host?: string;
  debug?: boolean;
  maxConnections?: number;
  timeout?: number;
  logLevel?: 'debug' | 'info' | 'warn' | 'error';
}
 
function createConfig(overrides: AppConfig): Required<AppConfig> {
  return {
    port: overrides.port ?? 3000,
    host: overrides.host ?? 'localhost',
    debug: overrides.debug ?? false,
    maxConnections: overrides.maxConnections ?? 100,
    timeout: overrides.timeout ?? 30000,
    logLevel: overrides.logLevel ?? 'info',
  };
}
 
// Usage
const config = createConfig({ port: 0, debug: false });
// port: 0 (correctly preserved!)
// debug: false (correctly preserved!)

Use Case 3: Event Handler Safety

interface EventHandlers {
  onClick?: (event: MouseEvent) => void;
  onHover?: (event: MouseEvent) => void;
  onFocus?: (event: FocusEvent) => void;
}
 
function bindEvents(element: HTMLElement, handlers: EventHandlers) {
  element.addEventListener('click', (e) => handlers.onClick?.(e));
  element.addEventListener('mouseenter', (e) => handlers.onHover?.(e));
  element.addEventListener('focus', (e) => handlers.onFocus?.(e));
}

Best Practices for Production

  1. Use ?? instead of || for default values β€” Only use || when you want to treat all falsy values as "missing"
  2. Chain optional access for deeply nested properties β€” a?.b?.c?.d is cleaner and safer than nested if-checks
  3. Use assertion functions for input validation β€” They provide type narrowing with built-in error throwing
  4. Don't overuse optional chaining β€” If a property should always exist, use regular access and let errors surface
  5. Combine with TypeScript strict mode β€” Optional chaining is most valuable with strictNullChecks: true
  6. Use recursive types for nested data structures β€” JSON, trees, and configurations benefit from recursive type aliases
  7. Prefer assertion functions over type guards for validation β€” When the code shouldn't continue with invalid data

Common Pitfalls and Solutions

PitfallImpactSolution
Using ?? with falsy values that matterLost intentional false/0/"" valuesUse ?? only for null/undefined fallbacks
Optional chaining in assignmentsSyntax errorUse conditional checks or logical assignment
Missing parentheses with ?? and ||Syntax errorAlways add explicit parentheses
Overusing optional chainingHides bugs where values should existUse regular access when values are required
Assertion functions not throwingType narrowing without runtime safetyAlways throw in assertion functions when condition fails
Recursive types too deepStack overflow in type checkerLimit recursion depth or use interfaces

Performance Optimization

Optional chaining has minimal runtime overhead. It compiles to simple conditional checks:

// TypeScript input
const city = user?.address?.city;
 
// Compiled JavaScript output (ES2020)
const city = user?.address?.city;
 
// Compiled JavaScript output (pre-ES2020)
var _a;
const city = (_a = user === null || user === void 0 ? void 0 : user.address) === null || _a === void 0 ? void 0 : _a.city;

The nullish coalescing operator compiles similarly:

// TypeScript input
const value = someValue ?? 'default';
 
// Compiled JavaScript (ES2020)
const value = someValue ?? 'default';
 
// Compiled JavaScript (pre-ES2020)
const value = someValue !== null && someValue !== void 0 ? someValue : 'default';

Comparison with Alternatives

FeatureOptional Chainingif checkslodash.getRamda.path
Type safetyβœ… Fullβœ… Full❌ None❌ None
SyntaxCleanVerboseFunction callFunction call
PerformanceNativeNativeLibrary overheadLibrary overhead
Short-circuitYesYesN/AN/A
Works with callsYesN/ANoNo
IDE supportFull autocompleteFullPartialPartial

Testing Strategies

describe('Optional Chaining', () => {
  test('handles null intermediate values', () => {
    const user: { address?: { city?: string } } = { address: undefined };
    expect(user?.address?.city).toBeUndefined();
  });
 
  test('handles missing properties', () => {
    const user: { address?: { city?: string } } = {};
    expect(user?.address?.city).toBeUndefined();
  });
 
  test('passes through valid values', () => {
    const user = { address: { city: 'Springfield' } };
    expect(user?.address?.city).toBe('Springfield');
  });
});
 
describe('Nullish Coalescing', () => {
  test('preserves zero', () => {
    const value = 0 ?? 42;
    expect(value).toBe(0);
  });
 
  test('preserves empty string', () => {
    const value = '' ?? 'default';
    expect(value).toBe('');
  });
 
  test('preserves false', () => {
    const value = false ?? true;
    expect(value).toBe(false);
  });
 
  test('falls back for null', () => {
    const value = null ?? 'default';
    expect(value).toBe('default');
  });
 
  test('falls back for undefined', () => {
    const value = undefined ?? 'default';
    expect(value).toBe('default');
  });
});
 
describe('Assertion Functions', () => {
  test('passes for valid values', () => {
    expect(() => assertIsString('hello')).not.toThrow();
  });
 
  test('throws for invalid values', () => {
    expect(() => assertIsString(42)).toThrow('Expected string');
  });
 
  test('narrowing works after assertion', () => {
    const value: unknown = 'hello';
    assertIsString(value);
    // TypeScript knows value is string here
    expect(value.toUpperCase()).toBe('HELLO');
  });
});

Future Outlook

TypeScript 3.7's features have become foundational to modern TypeScript development:

  1. ES2020 standardization: Optional chaining and nullish coalescing are now part of the JavaScript standard
  2. Universal browser support: All modern browsers support these operators natively
  3. Pattern for future features: Assertion functions established a pattern for type narrowing functions
  4. Recursive types enable complex modeling: JSON, GraphQL, and configuration types benefit greatly

Conclusion

TypeScript 3.7 was a landmark release that introduced features developers use daily. Optional chaining and nullish coalescing eliminated entire categories of null-related bugs, assertion functions provided a new way to validate data with type safety, and recursive type aliases enabled modeling complex nested structures.

Key takeaways:

  1. Optional chaining (?.) β€” Safely access deeply nested properties without verbose null checks
  2. Nullish coalescing (??) β€” Provide default values only for null/undefined, preserving intentional falsy values
  3. Assertion functions β€” Validate data and narrow types with a single function call
  4. Recursive type aliases β€” Model complex nested structures like JSON and trees
  5. These features are now standard β€” They're part of ES2020 and supported everywhere

If you're writing TypeScript today and not using these features, you're writing more code than necessary and potentially introducing bugs that TypeScript 3.7 was designed to prevent.