What is wrong with optional chaining and how to fix it

macsikora

Pragmatic Maciej

Posted on November 7, 2019

What is wrong with optional chaining and how to fix it

Edit:
There is nothing wrong with optional chaining, the feature is related to idiomatic absence value in JS, and it is "null | undefined". The operator tries to address issues of previously used &&. This article tries to make a point that JS has Nullable, and not Optional. I don't agree anymore with points I made here, but leaving this article untouched.

Optional chaining, fresh feature released in TypeScript 3.7, as it went lately into stage 3 of ECMAScript standard. This is a great feature but there are some issues with using it. Issues are maybe not fully related with the proposition itself, but more with current state of things, and how JS needs to be compatible backward in order to not break the web.

The good part

Let's start from what the feature solves. And it solves two issues:

  1. Nested conditional checks in nested nullable properties
  2. Falsy, Truthy issues during checks mentioned in point 1

The first

Instead of nested conditions, or many && we use ?..

// the logical and operator way
x && x.y && x.y.z
// the optional chaining way
x?.y?.z
Enter fullscreen mode Exit fullscreen mode

Also it is very nice for using methods in objects. Consider:

x?.y?.z?.filter(filterFunc) // where x, y, z are nullable values and z is an array
Enter fullscreen mode Exit fullscreen mode

The second

Different way of viewing what really means no value. Optional chaining brings a new rule to the table, instead of considering something as Falsy null | undefined | empty string | 0 | NaN | false. Optional chaining simplifies above and removes a lot of errors by saying that values considered as no value are only two - null | undefined.

Examine the code which works badly:

function getLengthOfStr(x) {
  return x && x.s && x.s.length; 
}
getLengthOfStr({s: ''}) // returns empty string!
Enter fullscreen mode Exit fullscreen mode

For empty string {s: ''} it should give us 0, but it will return empty string!. Optional chaining fix that nicely:

function getLengthOfStr(x) {
  return x?.s?.length; 
}
getLengthOfStr({s: ''}) // return correctly 0
Enter fullscreen mode Exit fullscreen mode

The bad part

That is great feature, but is also highly not consistent with the previous behaviors of the language. Consider below code:

const value = x?.y?.z; // z is a optional number
if (value) {
    return value + 1;
}

// or more concise
if (x?.y?.z) {
    return x.y.z + 1;
}
Enter fullscreen mode Exit fullscreen mode

Can you spot the issue?

The issue is in different behavior of new concept with the old one. In situation where z equals 0, this code would not add 1, as if is working by previous rules, so 0 is considered as Falsy. What a crap :(.

The fix is:

const value = x?.y?.z; // z is a number
if (value !== null && value !== undefined) {
    return value + 1;
}
Enter fullscreen mode Exit fullscreen mode

So the thing is that we need to use old, good solution like:

// simplified typing with use of any
function isNull(x: any) {
  return x === null || x === undefined;
}
const value = x?.y?.z; // z is a number
if (!isNull(value)) {
    return value + 1;
}
Enter fullscreen mode Exit fullscreen mode

Better but this shows that the new feature is crippled by it's descendants. Inconsistency of the language is really quite an issue, even bigger now after this change.

To be clear the change is very positive, but if you imagine code like a && b ?? c?.d || x it can be really hard to reason about that.

That is not the end. Lets say I do have a function which I want to call on the property which is a result of the optional chaining. We can do that by previous && operator. Below example

// func - function which works on NonNullable value
// it can be applied by previous && syntax
x && x.y && x.y.z && func(x.y.z)
Enter fullscreen mode Exit fullscreen mode

Can it be done like that in the new one? Nope, it can't :(. We need to use && again.

 x?.y?.z && func(x.y.z)
Enter fullscreen mode Exit fullscreen mode

Unfortunately both versions have the same issue, for z being empty string, it does not call func function. Another issue is that in the second we join two operations which have totally different rules of behavior. Implicit complexity is arising.

How then properly call this function on the optional chaining result?

// lets create another typeguard with proper typying
function isNotNull<A>(x: A): x is NonNullable<A> {
  return x!== null && x!== undefined;
}

isNotNull(x?.y?.z) && func(x.y.z) // nope it can evaluate to true/false but is also a type error
isNotNull(x?.y?.z) ? func(x.y.z) : null // nice, but TS has an issue with that, so doesn't work

// proper one:
const tmp = x?.y?.z;
isNotNull(tmp) ? func(tmp) : null // works
Enter fullscreen mode Exit fullscreen mode

As you can see, there needs to be additional check before we can use the computation result as an argument of another function. That is bad. Also the fact isNotNull(x?.y?.z) ? func(x.y.z) : null is not working looks like TypeScipt bug. That is why I've created such - optional chaining not works with type guards.

In other words optional chaining has a problem with dealing with any computation which needs to be done on the result of it or in the middle of the chain. There is no possibility to chain custom expression working on the positive result of optional chaining. This always needs to be done by another conditions, and these condition have a different view on what the hell means no value by the Falsy/Truthy rules.

Fixing the issue

This issue not exists in functional programming constructs like Maybe (known also as Optional), where it is possible to call function on positive result of the optional chain (via map or chain functions). What exactly optional chaining is missing is a Functor behavior, but the problem is - there is no additional computation context where we could be having a Functor. ?. can be considered as kind of chain/flatMap but in limited scope of object methods and properties accessing. So it is a flatMap where the choice is only get property functions, but still it is something.

Maybe is a sum type which has two value constructors - Some<Value> | None. In order to use new syntax of optional chaining, but have a power of Maybe we can do a neat trick. As we know that optional chaining treads None as null | undefined, that means that our Maybe could be doing the same. The second is - optional chaining works nicely with methods, as methods are just callable object properties. Taking these two, lets create implementation of Maybe which uses both things.

type None = null | undefined; // no value is represented like in optional chaining
type Maybe<ValueType> = Some<ValueType> | None;
Enter fullscreen mode Exit fullscreen mode

Ok, so we share the same definition of empty value between our new construct and optional chaining. Now Maybe implementation.

Caution our Maybe will be an instance of Functor, but will not be an instance of Monad, as we will use optional chaining to gain chaining powers.

class Some<ValueType> {
  value: ValueType;
  constructor(value: ValueType) {
    this.value = value;
  }
  map<NextValueType>(f: (x: ValueType) => NextValueType): Some<NextValueType> {
    return new Some(f(this.value));
  }
  get() {
    return this.value; // just return plain data
  }
} 
type None = null | undefined;
type Maybe<ValueType> = Some<ValueType> | None;

// value constructor / alias on new Some
const some = <ValueType>(v: ValueType) => new Some(v);
Enter fullscreen mode Exit fullscreen mode

Also take a look that TS automatically treads class definition as a type definition. So we have implementation and type in one language construct.

Now lets use this construct with optional chaining. I will use similar structure which I've presented in the previous examples, but with using of the new construct.

type NestedType = {
    y?: {
      z?: Maybe<number>  // number in optional context
    }
}

// version with using of our Maybe construct methods
function add1(x:NestedType) {
  return x?.y?.z?.map(z => z + 1).get()
}
add1({y: {z: some(1)}}) // result is 2
add1({y: {z: some(0)}}) // result is 1
add1({y: {}}) // result undefined
add1({}) // result is undefined

// compare to version without a Maybe and Functor features
function add1(x) {
  const v = x?.y?.z;
  if (isNotNull(v)) {
    return v + 1;
  }
  return null;
}

Enter fullscreen mode Exit fullscreen mode

Conclusion. By some effort and using additional abstractions (Functor) it is possible to use optional chaining with functions and without dealing with additional conditions and implicit complexity. Of course as always there is a trade-off, and here this additional abstraction is a wrapper over standard plain data. But this abstraction gives us super powers of reusing functions with not optional arguments inside optional context.

Additional thoughts. Some of you have an issue that this article is kinda about Falsy/Truthy issues and not new operator issues. That was really not my intention. It's more about the whole, so how much problems we still have even after introduction of the operator, and main point is you cannot use it without additional conditions as it lacks possibility of mapping it's positive result.

💖 💪 🙅 🚩
macsikora
Pragmatic Maciej

Posted on November 7, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related