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:
| Feature | Type Guard | Assertion Function |
|---|---|---|
| Return Type | value is Type | asserts value is Type |
| Usage | In conditionals | Standalone statement |
| On Failure | Returns false | Throws exception |
| Control Flow | Branches (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 fornull/undefined, not falsy values like0or''. - Assertion Functions combine runtime validation with compile-time type narrowing.
- These features work together to eliminate entire categories of
TypeError: Cannot read property of undefinederrors. - 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.