Optional Chaining: The ?. Operator in TypeScript
TypeScript 3.7 added support for the ?.
operator, also known as the optional chaining operator. We can use optional chaining to descend into an object whose properties potentially hold the values null
or undefined
without writing any null checks for intermediate properties.
Optional chaining is not a feature specific to TypeScript. The ?.
operator got added to the ECMAScript standard as part of ES2020. All modern browsers natively support optional chaining (not including IE11).
In this post, I will go over the following three optional chaining operators and explain why we might want to use them in our TypeScript or JavaScript code:
?.
?.[]
?.()
#Motivation
Let's start by looking at a real-world example in which optional chaining comes in handy. I've defined a serializeJSON
function that takes in any value and serializes it as JSON. I'm passing a user object with two properties to the function:
function serializeJSON(value: any) {
return JSON.stringify(value);
}
const user = {
name: "Marius Schulz",
twitter: "mariusschulz",
};
const json = serializeJSON(user);
console.log(json);
The program prints the following output to the console:
{"name":"Marius Schulz","twitter":"mariusschulz"}
Now let's say that we want to let callers of our function specify the indentation level. We'll define a SerializationOptions
type and add an options
parameter to the serializeJSON
function. We'll retrieve the indentation level from the options.formatting.indent
property:
type SerializationOptions = {
formatting: {
indent: number;
};
};
function serializeJSON(value: any, options: SerializationOptions) {
const indent = options.formatting.indent;
return JSON.stringify(value, null, indent);
}
We can now specify an indentation level of two spaces when calling serializeJSON
like this:
const user = {
name: "Marius Schulz",
twitter: "mariusschulz",
};
const json = serializeJSON(user, {
formatting: {
indent: 2,
},
});
console.log(json);
As we would expect, the resulting JSON is now indented with two spaces and broken across multiple lines:
{
"name": "Marius Schulz",
"twitter": "mariusschulz"
}
Typically, options
parameters like the one we introduced here are optional. Callers of the function may specify an options object, but they're not required to. Let's adjust our function signature accordingly and make the options
parameter optional by appending a question mark to the parameter name:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options.formatting.indent;
return JSON.stringify(value, null, indent);
}
Assuming we have the --strictNullChecks
option enabled in our TypeScript project (which is part of the --strict
family of compiler options), TypeScript should now report the following type error in our options.formatting.indent
expression:
Object is possibly 'undefined'.
The options
parameter is optional, and as a result it might hold the value undefined
. We should first check whether options
holds the value undefined
before accessing options.formatting
, otherwise we risk getting an error at runtime:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options !== undefined
? options.formatting.indent
: undefined;
return JSON.stringify(value, null, indent);
}
We could also use a slightly more generic null check instead that will check for both null
and undefined
— note that we're deliberately using !=
instead of !==
in this case:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options != null
? options.formatting.indent
: undefined;
return JSON.stringify(value, null, indent);
}
Now the type error goes away. We can call the serializeJSON
function and pass it an options object with an explicit indentation level:
const json = serializeJSON(user, {
formatting: {
indent: 2,
},
});
Or we can call it without specifying an options object, in which case the indent
variable will hold the value undefined
and JSON.stringify
will use a default indentation level of zero:
const json = serializeJSON(user);
Both function calls above are type-correct. However, what if we also wanted to be able to call our serializeJSON
function like this?
const json = serializeJSON(user, {});
This is another common pattern you'll see. Options objects tend to declare some or all of their properties as optional so that callers of the function can specify as many (or as few) options as needed. We need to make the formatting
property in our SerializationOptions
type optional in order to support this pattern:
type SerializationOptions = {
formatting?: {
indent: number;
};
};
Notice the question mark after the name of the formatting
property. Now the serializeJSON(user, {})
call is type-correct, but TypeScript reports another type error when accessing options.formatting.indent
:
Object is possibly 'undefined'.
We'll need to add another null check here given that options.formatting
could now hold the value undefined
:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options != null
? options.formatting != null
? options.formatting.indent
: undefined
: undefined;
return JSON.stringify(value, null, indent);
}
This code is now type-correct, and it safely accesses the options.formatting.indent
property. These nested null checks are getting pretty unwieldy though, so let's see how we can simplify this property access using the optional chaining operator.
#The ?.
Operator: Dot Notation
We can use the ?.
operator to access options.formatting.indent
with checks for nullish values at every level of this property chain:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options?.formatting?.indent;
return JSON.stringify(value, null, indent);
}
The ECMAScript specification describes optional chaining as follows:
Optional chaining [is] a property access and function invocation operator that short-circuits if the value to access/invoke is nullish.
The JavaScript runtime evaluates the options?.formatting?.indent
expression as follows:
- If
options
holds the valuenull
orundefined
, produce the valueundefined
. - Otherwise, if
options.formatting
holds the valuenull
orundefined
, produce the valueundefined
. - Otherwise, produce the value of
options.formatting.indent
.
Note that the ?.
operator always produces the value undefined
when it stops descending into a property chain, even when it encounters the value null
. TypeScript models this behavior in its type system. In the following example, TypeScript infers the indent
local variable to be of type number | undefined
:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options?.formatting?.indent;
return JSON.stringify(value, null, indent);
}
Thanks to optional chaining, this code is a lot more succinct and just as type-safe as before.
#The ?.[]
Operator: Bracket Notation
Next, let's now look at the ?.[]
operator, another operator in the optional chaining family.
Let's say that our indent
property on the SerializationOptions
type was called indent-level
instead. We'll need to use quotes to define a property that has a hyphen in its name:
type SerializationOptions = {
formatting?: {
"indent-level": number;
};
};
We could now specify a value for the indent-level
property like this when calling the serializeJSON
function:
const json = serializeJSON(user, {
formatting: {
"indent-level": 2,
},
});
However, the following attempt to access the indent-level
property using optional chaining is a syntax error:
const indent = options?.formatting?."indent-level";
We cannot use the ?.
operator directly followed by a string literal — that would be invalid syntax. Instead, we can use the bracket notation of optional chaining and access the indent-level
property using the ?.[]
operator:
const indent = options?.formatting?.["indent-level"];
Here's our complete serializeJSON
function:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options?.formatting?.["indent-level"];
return JSON.stringify(value, null, indent);
}
It's pretty much the same as before, aside from additional square brackets for the final property access.
#The ?.()
Operator: Method Calls
The third and final operator in the optional chaining family is ?.()
. We can use the ?.()
operator to invoke a method which may not exist.
To see when this operator is useful, let's change our SerializationOptions
type once again. We'll replace the indent
property (typed as a number) by a getIndent
property (typed as a parameterless function returning a number):
type SerializationOptions = {
formatting?: {
getIndent?: () => number;
};
};
We can call our serializeJSON
function and specify an indentation level of two as follows:
const json = serializeJSON(user, {
formatting: {
getIndent: () => 2,
},
});
To get the indentation level within our serializeJSON
function, we can use the ?.()
operator to conditionally invoke the getIndent
method if (and only if) it is defined:
const indent = options?.formatting?.getIndent?.();
If the getIndent
method is not defined, no attempt will be made to invoke it. The entire property chain will evaluate to undefined
in that case, avoiding the infamous "getIndent is not a function" error.
Here's our complete serializeJSON
function once again:
function serializeJSON(value: any, options?: SerializationOptions) {
const indent = options?.formatting?.getIndent?.();
return JSON.stringify(value, null, indent);
}
#Compiling Optional Chaining to Older JavaScript
Now that we've seen how the optional chaining operators work and how they're type-checked, let's have a look at the compiled JavaScript which the TypeScript compiler emits when targeting older JavaScript versions.
Here's the JavaScript code that the TypeScript compiler will emit, with whitespace adjusted for readability:
function serializeJSON(value, options) {
var _a, _b;
var indent =
(_b =
(_a =
options === null || options === void 0
? void 0
: options.formatting) === null || _a === void 0
? void 0
: _a.getIndent) === null || _b === void 0
? void 0
: _b.call(_a);
return JSON.stringify(value, null, indent);
}
There's quite a lot going on in the assignment to the indent
variable. Let's simplify the code step by step. We'll start by renaming the local variables _a
and _b
to formatting
and getIndent
, respectively:
function serializeJSON(value, options) {
var formatting, getIndent;
var indent =
(getIndent =
(formatting =
options === null || options === void 0
? void 0
: options.formatting) === null || formatting === void 0
? void 0
: formatting.getIndent) === null || getIndent === void 0
? void 0
: getIndent.call(formatting);
return JSON.stringify(value, null, indent);
}
Next, let's address the void 0
expression. The void
operator always produces the value undefined
, no matter what value it's applied to. We can replace the void 0
expression by the value undefined
directly:
function serializeJSON(value, options) {
var formatting, getIndent;
var indent =
(getIndent =
(formatting =
options === null || options === undefined
? undefined
: options.formatting) === null || formatting === undefined
? undefined
: formatting.getIndent) === null || getIndent === undefined
? undefined
: getIndent.call(formatting);
return JSON.stringify(value, null, indent);
}
Next, let's extract the assignment to the formatting
variable into a separate statement:
function serializeJSON(value, options) {
var formatting =
options === null || options === undefined
? undefined
: options.formatting;
var getIndent;
var indent =
(getIndent =
formatting === null || formatting === undefined
? undefined
: formatting.getIndent) === null || getIndent === undefined
? undefined
: getIndent.call(formatting);
return JSON.stringify(value, null, indent);
}
Let's do the same with the assignment to getIndent
and add some whitespace:
function serializeJSON(value, options) {
var formatting =
options === null || options === undefined
? undefined
: options.formatting;
var getIndent =
formatting === null || formatting === undefined
? undefined
: formatting.getIndent;
var indent =
getIndent === null || getIndent === undefined
? undefined
: getIndent.call(formatting);
return JSON.stringify(value, null, indent);
}
Lastly, let's combine the checks using ===
for the values null
and undefined
into a single check using the ==
operator. Unless we're dealing with the special document.all
value in our null checks, the two are equivalent:
function serializeJSON(value, options) {
var formatting = options == null
? undefined
: options.formatting;
var getIndent = formatting == null
? undefined
: formatting.getIndent;
var indent = getIndent == null
? undefined
: getIndent.call(formatting);
return JSON.stringify(value, null, indent);
}
Now the structure of the code is a lot more apparent. You can see that TypeScript is emitting the null checks that we would have written ourselves if we hadn't been able to use the optional chaining operators.
This article and 44 others are part of the TypeScript Evolution series. Have a look!