TypeScript 3.7: Optional Chaining, Nullish Coalescing, and Assertion Functions Deep Dive

TypeScript 3.7, released in November 2019, introduced three transformative features that fundamentally changed how developers write safe, expressive code: Optional Chaining, Nullish Coalescing, and Assertion Functions. These aren’t just syntactic sugar—they represent a philosophical shift toward eliminating entire categories of runtime errors at compile time. In this exhaustive guide, we’ll explore each feature in depth, understand the problems they solve, and see how they integrate into real-world enterprise applications.

Optional Chaining: The End of Defensive Coding

Before optional chaining, accessing deeply nested properties was a minefield. Consider fetching user profile data from an API where any level of the object might be undefined:

// The old way: Defensive programming nightmare
interface User {
  profile?: {
    address?: {
      city?: string;
    };
  };
}

function getCityOldWay(user: User | undefined): string | undefined {
  if (user) {
    if (user.profile) {
      if (user.profile.address) {
        return user.profile.address.city;
      }
    }
  }
  return undefined;
}

// Or using logical AND (still verbose)
function getCityWithAnd(user: User | undefined): string | undefined {
  return user && user.profile && user.profile.address && user.profile.address.city;
}

Optional chaining eliminates this boilerplate with the ?. operator:

// The TypeScript 3.7 way
function getCity(user: User | undefined): string | undefined {
  return user?.profile?.address?.city;
}

// Method calls
const length = user?.profile?.getFullName()?.length;

// Array access
const firstFriend = user?.friends?.[0]?.name;

// Dynamic property access
const dynamicProp = user?.profile?.[propertyName];

How Optional Chaining Works Under the Hood

The TypeScript compiler transforms optional chaining into conditional checks. Understanding this transformation helps debug and optimize code:

// TypeScript: user?.profile?.city
// Compiles to:
var _a, _b;
(_b = (_a = user) === null || _a === void 0 ? void 0 : _a.profile) === null || _b === void 0 ? void 0 : _b.city;

Each ?. creates a check for null and undefined, short-circuiting to undefined if either is encountered.

Nullish Coalescing: The Right Default Value

The logical OR operator (||) has been used for default values for decades, but it has a critical flaw: it treats 0, '' (empty string), and false as falsy, overwriting legitimate values.

// The problem with ||
const count = 0;
const displayCount = count || 'No items'; // 'No items' - WRONG!

const name = '';
const displayName = name || 'Anonymous'; // 'Anonymous' - WRONG!

const enabled = false;
const isEnabled = enabled || true; // true - WRONG!

The nullish coalescing operator (??) only triggers on null or undefined:

// The correct way with ??
const count = 0;
const displayCount = count ?? 'No items'; // 0 - Correct!

const name = '';
const displayName = name ?? 'Anonymous'; // '' - Correct!

const enabled = false;
const isEnabled = enabled ?? true; // false - Correct!

// Combined with optional chaining
const pageSize = config?.pagination?.pageSize ?? 25;

Operator Precedence Matters

You cannot mix ?? with || or && without parentheses. TypeScript enforces this:

// Error: Cannot be used with || or && without parentheses
// const value = a ?? b || c;

// Correct: Use parentheses to clarify intent
const value1 = (a ?? b) || c;
const value2 = a ?? (b || c);

Assertion Functions: Runtime Safety with Type Narrowing

Assertion functions bridge runtime validation with compile-time type narrowing. When an assertion function passes, TypeScript narrows the type in subsequent code:

// Define an assertion function
function assertIsString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error(`Expected string but got ${typeof value}`);
  }
}

function processInput(input: unknown): void {
  // input is 'unknown' here
  assertIsString(input);
  // input is 'string' here - TypeScript knows!
  console.log(input.toUpperCase());
}

// More complex assertion
interface User {
  id: string;
  name: string;
  email: string;
}

function assertIsUser(value: unknown): asserts value is User {
  if (!value || typeof value !== 'object') {
    throw new Error('Value is not an object');
  }
  const obj = value as Record<string, unknown>;
  if (typeof obj.id !== 'string') throw new Error('Invalid id');
  if (typeof obj.name !== 'string') throw new Error('Invalid name');
  if (typeof obj.email !== 'string') throw new Error('Invalid email');
}

async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();
  assertIsUser(data); // Throws if invalid, narrows to User if valid
  return data; // data is User here
}

Assertion Functions vs Type Guards

While both narrow types, they serve different purposes:

FeatureType GuardAssertion Function
Return Typevalue is Typeasserts value is Type
UsageIn conditionalsStandalone statement
On FailureReturns falseThrows exception
Control FlowBranches (if/else)Continues or throws

Real-World Enterprise Patterns

API Response Validation

interface ApiResponse<T> {
  data?: T;
  error?: {
    code: string;
    message: string;
  };
}

async function fetchWithValidation<T>(
  url: string,
  validator: (data: unknown) => asserts data is T
): Promise<T> {
  const response = await fetch(url);
  const json: ApiResponse<unknown> = await response.json();
  
  if (json.error) {
    throw new Error(`${json.error.code}: ${json.error.message}`);
  }
  
  if (json.data === undefined) {
    throw new Error('Response missing data');
  }
  
  validator(json.data);
  return json.data;
}

Configuration with Defaults

interface AppConfig {
  apiUrl?: string;
  timeout?: number;
  retries?: number;
  debug?: boolean;
}

function getConfig(userConfig?: AppConfig) {
  return {
    apiUrl: userConfig?.apiUrl ?? 'https://api.example.com',
    timeout: userConfig?.timeout ?? 30000,
    retries: userConfig?.retries ?? 3,
    debug: userConfig?.debug ?? false
  };
}

// Even works with zero and false
const config = getConfig({ timeout: 0, debug: false });
// { apiUrl: 'https://api...', timeout: 0, retries: 3, debug: false }

Performance Considerations

Optional chaining is compiled to efficient conditional checks. In hot paths, be aware that each ?. adds a runtime check. For performance-critical code with stable data shapes, consider traditional property access after validating the data structure once.

Key Takeaways

  • Optional Chaining (?.) eliminates verbose null checks for nested property access.
  • Nullish Coalescing (??) provides defaults only for null/undefined, not falsy values like 0 or ''.
  • Assertion Functions combine runtime validation with compile-time type narrowing.
  • These features work together to eliminate entire categories of TypeError: Cannot read property of undefined errors.
  • Use ?? for configuration defaults; use || only when you truly want to treat falsy values as missing.

Conclusion

TypeScript 3.7’s features represent a maturation of the language toward safer, more expressive code. By adopting optional chaining, nullish coalescing, and assertion functions, teams can dramatically reduce null-related bugs while writing cleaner code. These patterns are now industry standard and should be adopted in all new TypeScript projects.

References


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.