Repository: jsonata-js/jsonata
Pull request #748 (2025-07-01 13:34:58)

feat: add default/elvis and coalescing operator
This PR continues the work of @markmelville in PR https://github.com/jsonata-js/jsonata/pull/638 for the elvis/default operator `A ?: B` and adds the coalescing operator `A ?? B` as described by @andrew-coleman 
> Coalescing operator `A ?? B` which will evaluate to B iff A does not exist (empty sequence). Equivalent to `[A, B](0)` (from XPath).
> https://github.com/jsonata-js/jsonata/pull/638#issuecomment-1584301119

I followed the logic of `$exists()` when evaluating the LHS which only checks for `undefined`, that means other falsy values like `null` are evaluated as true. This could be changed to also include `null` to match the behavior of JavaScript's `??` operator.

markmelville: Thanks for picking this up. When the feedback on my PR was that the `??` was probably the more important to prioritize, I admittedly lost my steam. My feedback here is that the elvis operator should get some more test cases to show that that rhs would be selected for defined, falsy values such as 0 or "" (where as the null coalescing would not).

Patch:
diff --git a/docs/other-operators.md b/docs/other-operators.md
index c56f793..ab8f627 100644
--- a/docs/other-operators.md
+++ b/docs/other-operators.md
@@ -25,6 +25,36 @@ __Example__
 
 `Price < 50 ? "Cheap" : "Expensive"`
 
+## `?:` (Default/Elvis)
+
+The default (or "elvis") operator returns the left-hand side if it has an effective Boolean value of `true`, otherwise it returns the right-hand side. This is useful for providing fallback values when an expression may evaluate to a value with an effective Boolean value of `false` (such as `null`, `false`, `0`, `''`, or `undefined`).
+
+__Syntax__
+
+`<expr1> ?: <expr2>`
+
+__Example__
+
+`foo.bar ?: 'default'` => `'default'` (if `foo.bar` is evaluates to Boolean `false`)
+
+## `??` (Coalescing)
+
+The coalescing operator returns the left-hand side if it is defined (not `undefined`), otherwise it returns the right-hand side. This is useful for providing fallback values only when the left-hand side is missing or not present (empty sequence), but not for other values with an effective Boolean value of `false` like `0`, `false`, or `''`.
+
+__Syntax__
+
+`<expr1> ?? <expr2>`
+
+__Example__
+
+`foo.bar ?? 42` => `42` (if `foo.bar` is undefined)
+
+`foo.bar ?? 'default'` => `'default'` (if `foo.bar` is undefined)
+
+`0 ?? 1` => `0`
+
+`'' ?? 'fallback'` => `''`
+
 ## `:=` (Variable binding)
 
 The variable binding operator is used to bind the value of the RHS to the variable name defined on the LHS.  The variable binding is scoped to the current block and any nested blocks.  It is an error if the LHS is not a `$` followed by a valid variable name.
diff --git a/docs/programming.md b/docs/programming.md
index d09aab5..039fa9e 100644
--- a/docs/programming.md
+++ b/docs/programming.md
@@ -40,6 +40,8 @@ Produces [this](http://try.jsonata.org/ryYn78Q0m), if you're interested!
 
 ## Conditional logic
 
+### Ternary operator (`? :`)
+
 If/then/else constructs can be written using the ternary operator "? :".
 
 `predicate ? expr1 : expr2`
@@ -68,6 +70,84 @@ __Examples__
 ]</div>
 </div>
 
+### Elvis/Default operator (`?:`)
+
+The default (or "elvis") operator is syntactic sugar for a common pattern using the ternary operator. It returns the left-hand side if it has an effective Boolean value of `true`, otherwise it returns the right-hand side.
+
+`expr1 ?: expr2`
+
+This is equivalent to:
+
+`expr1 ? expr1 : expr2`
+
+The elvis operator is useful for providing fallback values when an expression may evaluate to a value with an effective Boolean value of `false`, without having to repeat the expression twice as you would with the ternary operator.
+
+__Examples__
+
+<div class="jsonata-ex">
+  <div>Account.Order.Product.{
+    `Product Name`: $.'Product Name',
+    `Category`: $.Category ?: "Uncategorized"
+}</div>
+  <div>[
+  {
+    "Product Name": "Bowler Hat",
+    "Category": "Uncategorized"
+  },
+  {
+    "Product Name": "Trilby hat",
+    "Category": "Uncategorized"
+  },
+  {
+    "Product Name": "Bowler Hat",
+    "Category": "Uncategorized"
+  },
+  {
+    "Product Name": "Cloak",
+    "Category": "Uncategorized"
+  }
+]</div>
+</div>
+
+### Coalescing operator (`??`)
+
+The coalescing operator is syntactic sugar for a common pattern using the ternary operator with the `$exists` function. It returns the left-hand side if it is defined (not `undefined`), otherwise it returns the right-hand side.
+
+`expr1 ?? expr2`
+
+This is equivalent to:
+
+`$exists(expr1) ? expr1 : expr2`
+
+The coalescing operator is useful for providing fallback values only when the left-hand side is missing or not present (empty sequence), but not for other values with an effective Boolean value of `false` like `0`, `false`, or `''`. It avoids having to evaluate the expression twice and explicitly use the `$exists` function as you would with the ternary operator.
+
+__Examples__
+
+<div class="jsonata-ex">
+  <div>Account.Order.{
+    "OrderID": OrderID, 
+    Rating": ($sum(Product.Rating) / $count(Product.Rating)) ?? 0
+}</div>
+  <div>[
+  {
+    "OrderID": "order101",
+    "Rating": 5
+  },
+  {
+    "OrderID": "order102",
+    "Rating": 3
+  },
+  {
+    "OrderID": "order103",
+    "Rating": 4
+  },
+  {
+    "OrderID": "order104",
+    "Rating": 2
+  }
+]</div>
+</div>
+
 ## Variables
 
 Any name that starts with a dollar '$' is a variable.  A variable is a named reference to a value.  The value can be one of any type in the language's [type system](processing#the-jsonata-type-system).
diff --git a/src/functions.js b/src/functions.js
index c3fff7a..3b28e94 100644
--- a/src/functions.js
+++ b/src/functions.js
@@ -1420,7 +1420,7 @@ const functions = (() => {
             if (arg !== 0) {
                 result = true;
             }
-        } else if (arg !== null && typeof arg === 'object') {
+        } else if (arg !== null && typeof arg === 'object' && !isFunction(arg)) {
             if (Object.keys(arg).length > 0) {
                 result = true;
             }
diff --git a/src/parser.js b/src/parser.js
index 9cffaf3..a6d4223 100644
--- a/src/parser.js
+++ b/src/parser.js
@@ -40,6 +40,8 @@ const parser = (() => {
         '<=': 40,
         '>=': 40,
         '~>': 40,
+        '?:': 40,
+        '??': 40,
         'and': 30,
         'or': 25,
         'in': 40,
@@ -198,6 +200,16 @@ const parser = (() => {
                 position += 2;
                 return create('operator', '~>');
             }
+            if (currentChar === '?' && path.charAt(position + 1) === ':') {
+                // ?: default / elvis operator
+                position += 2;
+                return create('operator', '?:');
+            }
+            if (currentChar === '?' && path.charAt(position + 1) === '?') {
+                // ?? coalescing operator
+                position += 2;
+                return create('operator', '??');
+            }
             // test for single char operators
             if (Object.prototype.hasOwnProperty.call(operators, currentChar)) {
                 position++;
@@ -566,6 +578,20 @@ const parser = (() => {
         prefix("-"); // unary numeric negation
         infix("~>"); // function application
 
+        // coalescing operator
+        infix("??", operators['??'], function (left) {
+            this.type = 'condition';
+            this.condition = {
+                type: 'function',
+                value: '(',
+                procedure: { type: 'variable', value: 'exists' },
+                arguments: [left]
+            };
+            this.then = left;
+            this.else = expression(0);
+            return this;
+        });
+
         infixr("(error)", 10, function (left) {
             this.lhs = left;
 
@@ -849,6 +875,15 @@ const parser = (() => {
             return this;
         });
 
+        // elvis/default operator
+        infix("?:", operators['?:'], function (left) {
+            this.type = 'condition';
+            this.condition = left;
+            this.then = left;
+            this.else = expression(0);
+            return this;
+        });
+
         // object transformer
         prefix("|", function () {
             this.type = 'transform';


Test patch:
diff --git a/test/test-suite/groups/coalescing-operator/case000.json b/test/test-suite/groups/coalescing-operator/case000.json
new file mode 100644
index 0000000..546e66a
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case000.json
@@ -0,0 +1,6 @@
+{
+    "expr": "bar ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 98
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case001.json b/test/test-suite/groups/coalescing-operator/case001.json
new file mode 100644
index 0000000..cbb7077
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case001.json
@@ -0,0 +1,6 @@
+{
+    "expr": "foo.bar ?? 98",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case002.json b/test/test-suite/groups/coalescing-operator/case002.json
new file mode 100644
index 0000000..79480e1
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case002.json
@@ -0,0 +1,6 @@
+{
+    "expr": "foo.blah[0].baz.fud ?? 98",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": "hello"
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case003.json b/test/test-suite/groups/coalescing-operator/case003.json
new file mode 100644
index 0000000..6e1a9a5
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case003.json
@@ -0,0 +1,7 @@
+{
+    "description": "undefined property uses default number on rhs",
+    "expr": "baz ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case004.json b/test/test-suite/groups/coalescing-operator/case004.json
new file mode 100644
index 0000000..40624b6
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case004.json
@@ -0,0 +1,7 @@
+{
+    "description": "property missing on object uses default number on rhs",
+    "expr": "foo.baz ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case005.json b/test/test-suite/groups/coalescing-operator/case005.json
new file mode 100644
index 0000000..4e56001
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case005.json
@@ -0,0 +1,7 @@
+{
+    "description": "out of bounds index uses default number on rhs",
+    "expr": "foo.blah[9].baz.fud ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case006.json b/test/test-suite/groups/coalescing-operator/case006.json
new file mode 100644
index 0000000..802b738
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case006.json
@@ -0,0 +1,7 @@
+{
+    "description": "null is used",
+    "expr": "null ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": null
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case007.json b/test/test-suite/groups/coalescing-operator/case007.json
new file mode 100644
index 0000000..b00c844
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case007.json
@@ -0,0 +1,7 @@
+{
+    "description": "false is used",
+    "expr": "false ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": false
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case008.json b/test/test-suite/groups/coalescing-operator/case008.json
new file mode 100644
index 0000000..df6d590
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case008.json
@@ -0,0 +1,7 @@
+{
+    "description": "true is used",
+    "expr": "true ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": true
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case009.json b/test/test-suite/groups/coalescing-operator/case009.json
new file mode 100644
index 0000000..e812d73
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case009.json
@@ -0,0 +1,7 @@
+{
+    "description": "0 is used",
+    "expr": "0 ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 0
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case010.json b/test/test-suite/groups/coalescing-operator/case010.json
new file mode 100644
index 0000000..3251576
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case010.json
@@ -0,0 +1,7 @@
+{
+    "description": "empty array is used",
+    "expr": "[] ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": []
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case011.json b/test/test-suite/groups/coalescing-operator/case011.json
new file mode 100644
index 0000000..246a612
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case011.json
@@ -0,0 +1,7 @@
+{
+    "description": "empty object is used",
+    "expr": "{} ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": {}
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/coalescing-operator/case012.json b/test/test-suite/groups/coalescing-operator/case012.json
new file mode 100644
index 0000000..ec13c52
--- /dev/null
+++ b/test/test-suite/groups/coalescing-operator/case012.json
@@ -0,0 +1,7 @@
+{
+    "description": "empty string is used",
+    "expr": "\"\" ?? 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": ""
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case000.json b/test/test-suite/groups/default-operator/case000.json
new file mode 100644
index 0000000..1bb1460
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case000.json
@@ -0,0 +1,7 @@
+{
+    "description": "true is used",
+    "expr": "true ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": true
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case001.json b/test/test-suite/groups/default-operator/case001.json
new file mode 100644
index 0000000..6aab9ca
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case001.json
@@ -0,0 +1,7 @@
+{
+    "description": "false is not used",
+    "expr": "false ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case002.json b/test/test-suite/groups/default-operator/case002.json
new file mode 100644
index 0000000..9ccd3a8
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case002.json
@@ -0,0 +1,7 @@
+{
+    "description": "1 is used",
+    "expr": "1 ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 1
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case003.json b/test/test-suite/groups/default-operator/case003.json
new file mode 100644
index 0000000..51788d3
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case003.json
@@ -0,0 +1,7 @@
+{
+    "description": "0 is not used",
+    "expr": "0 ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case004.json b/test/test-suite/groups/default-operator/case004.json
new file mode 100644
index 0000000..0d648fa
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case004.json
@@ -0,0 +1,7 @@
+{
+    "description": "hello is used",
+    "expr": "\"hello\" ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": "hello"
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case005.json b/test/test-suite/groups/default-operator/case005.json
new file mode 100644
index 0000000..5e641cc
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case005.json
@@ -0,0 +1,7 @@
+{
+    "description": "empty string is not used",
+    "expr": "\"\" ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case006.json b/test/test-suite/groups/default-operator/case006.json
new file mode 100644
index 0000000..a8e4910
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case006.json
@@ -0,0 +1,9 @@
+{
+    "description": "[1] is used",
+    "expr": "[1] ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": [
+        1
+    ]
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case007.json b/test/test-suite/groups/default-operator/case007.json
new file mode 100644
index 0000000..1a9bc7a
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case007.json
@@ -0,0 +1,7 @@
+{
+    "description": "[0] is not used",
+    "expr": "[0] ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case008.json b/test/test-suite/groups/default-operator/case008.json
new file mode 100644
index 0000000..a3713d8
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case008.json
@@ -0,0 +1,7 @@
+{
+    "description": "empty array is not used",
+    "expr": "[] ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case009.json b/test/test-suite/groups/default-operator/case009.json
new file mode 100644
index 0000000..769791d
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case009.json
@@ -0,0 +1,9 @@
+{
+    "description": "object with property is used",
+    "expr": "{ \"a\": 1 } ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": {
+        "a": 1
+    }
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case010.json b/test/test-suite/groups/default-operator/case010.json
new file mode 100644
index 0000000..321bbcb
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case010.json
@@ -0,0 +1,7 @@
+{
+    "description": "empty object is not used",
+    "expr": "{} ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case011.json b/test/test-suite/groups/default-operator/case011.json
new file mode 100644
index 0000000..0d1e882
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case011.json
@@ -0,0 +1,7 @@
+{
+    "description": "function is not used",
+    "expr": "function(){true} ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case012.json b/test/test-suite/groups/default-operator/case012.json
new file mode 100644
index 0000000..e2437f6
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case012.json
@@ -0,0 +1,7 @@
+{
+    "description": "42 is used",
+    "expr": "Account.blah ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file
diff --git a/test/test-suite/groups/default-operator/case013.json b/test/test-suite/groups/default-operator/case013.json
new file mode 100644
index 0000000..e2437f6
--- /dev/null
+++ b/test/test-suite/groups/default-operator/case013.json
@@ -0,0 +1,7 @@
+{
+    "description": "42 is used",
+    "expr": "Account.blah ?: 42",
+    "dataset": "dataset0",
+    "bindings": {},
+    "result": 42
+}
\ No newline at end of file