Marius Schulz
Marius Schulz
Front End Engineer

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 value null or undefined, produce the value undefined.
  • Otherwise, if options.formatting holds the value null or undefined, produce the value undefined.
  • 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 post is part of the TypeScript Evolution series.