loading...

What is wrong with optional chaining and how to fix it

macsikora profile image Maciej Sikora Updated on ・6 min read

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

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

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!

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

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;
}

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;
}

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;
}

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)

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)

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

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;

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);

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;
}

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.

Discussion

pic
Editor guide
Collapse
marcotraspel profile image
Marco Traspel

Sorry that's bs.
You also don't go like
A = 0;
If (a)
....
And say oh that's stupid that 0 is falsy???

Collapse
macsikora profile image
Maciej Sikora Author

Thank you for the comment. But yes that is almost never what you want. Only in division 0 is kinda problem. In any other operation 0 is fully valid number, and there is no reason why you should not tread it like that. Rules like Falsy - null | undefined and Truthy everything else would be much much cleaner. I always think twice if empty array is T or is F. As string in many languages is represented as Char[] having [] T and '' F has totally no sense.

In the article I wanted to emphasize different thing really - that you cannot compose nicely with positive result of optional chaining and there always needs to be another condition. Again thanks for the comment.

Collapse
jhoofwijk profile image
jhoofwijk

I mostly agree with Marco. I really do love optional chaining.

For the issue with numbers in conditions: you can always force yourself to only use booleans in conditions using ts-lints strict-boolean-expressions (palantir.github.io/tslint/rules/st...). In my experience it is sometimes nice to have 0 evaluate to false (when checking array length for example) but generally this lint option could prevent quite some hard to find bugs.

Thread Thread
macsikora profile image
Maciej Sikora Author

Again the issue is that you need to do the condition on optional and you cannot just execute the expression on it. What is true in Optionals from languages like Swift or Java. Maybe I too much focused on Falsy/Truthy failures. But the point is you always need to do the last check, and this last check is error prone as it was before. Without this check (by mapping) the rules are far more concistent.

Thread Thread
vcicmanec profile image
vcicmanec

You'd need to do it anyway. It's not the fault of optional chaining, it's the fault of dynamic types and truthy-/falsy-ness being baked into the language.

If number of characters is your worry, just use if (z > -1). That will even do a limited type check for you, as objects will always fail.

Thread Thread
macsikora profile image
Maciej Sikora Author

Yes if you talk about the condition issue exactly. The issue is that optional chaining need to be used with previous bad parts baked into language, and because the whole chain checks null | undefined but the last evaluated value put into if will behave by the falsy | Truthy.

But the second is not fully related. The issue is you cannot execute safetly function on the result, you need to always do additional if before, so again it is error prone.

Collapse
vujevits profile image
Mark Vujevits

I think your example issue for the bad part has a flaw.
You should only use non-optional boolean values in a condition in JS/TS. If you want to check for existence, do: if (something != null), this will check if it's null or undefined, no need for helpers.
Problem solved :-)

Collapse
maricn profile image
Nikola Marić

nice post.. coming from java 8+ world, i totally see what's your issue here..

i like this Maybe construct and hope TSC will provide a first class support for mapping in similar way!

Collapse
mrdrummer profile image
MrDrummer

There is always ?? which will only check for null or undefined and not other falsy values - it's under "Nullish Coalescing":
devblogs.microsoft.com/typescript/...