Skip to content

Instantly share code, notes, and snippets.

@d-akara
Last active April 11, 2024 16:18
Show Gist options
  • Star 43 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save d-akara/6a87168db66fd8f032d2 to your computer and use it in GitHub Desktop.
Save d-akara/6a87168db66fd8f032d2 to your computer and use it in GitHub Desktop.
JavaScript Safe Navigation

Experimental Safe JavaScript Navigation

Implemented using ES6 Proxies and Symbols

The purpose of this function is to provide a way to avoid deep nested conditionals when traversing a hierarchy of objects. Some languages use an operator such as '?.' to perform this capability. This is sometimes called safe navigation or null conditional operators.

You can somewhat think of this as how a xpath select works. If any nodes along the path are not found, your result is simply not found without throwing an exception and without needing to check each individual node to see if it exists.

Suggestions for improvements welcome!

const nonNavigableTarget = Symbol();

function safe(target, defaultValue) {
   // If the target was already wrapped, return the wrapped target
   if (target && (typeof target === 'object') && (target[nonNavigableTarget])) return target;
  
   const _this = this;
   if ( typeof target === "function") return function() {
      return safe(target.apply(_this, arguments), defaultValue);
   };
  
   // wrap non object values which we can't futher navigate
   if ( typeof target !== "object" || target === null) {
      target = target || defaultValue;
      target = {[nonNavigableTarget]: {target: target, isResolved: !target || target === defaultValue}}; 
   }
   
   // Create a safe proxy for the target
   const proxy = new Proxy(target, {
       get: function(target, key) {
           // Resolve the actual value when the $ terminator key is used
           if (key==='$') {
              if (target[nonNavigableTarget]) return target[nonNavigableTarget].target;
              return target;
           }
           
           // We have already resolved to a non navigable value.  Keep returning what we already resolved if there are more lookups
           if (target[nonNavigableTarget] && target[nonNavigableTarget].isResolved) return safe(target[nonNavigableTarget].target, defaultValue);
           // When a property is requested, wrap it in a proxy
           return safe.call(target, target[key], defaultValue);
       },
       apply: function(target, thisArg, argumentsList) {
          // This can only be called on the proxy when there is an attempt to invoke a non function
          // function values are wrapped in a function outside of the proxy
          return safe(target[nonNavigableTarget].target, defaultValue);
       }     
    });
    return proxy;
}

Sample usage of safe navigation

let o = {
    name: "User1",
    address: {
        street: "513"
    },
    getAddress: function() {
      return this.address;
    },
    getNull: function() {
      return null;
    },
    isNull: null
};

// '.$' signifies the end of the expression and to resolve the value
safe(o).getAddress().street.$ === '513'
safe(o).name.$ === 'User1'

// Example using a default value
safe(o,'name').name.noName.noName2.$ === 'name';

// Example resolving to an object
safe(o).address.$ === o.address

// Example undefined resolutions
safe(o).address.city.country.street.$ === 'undefined'
safe(o).isNull.next.next.$ === 'undefined'

// Example calling a function
safe(o).getNull().street.$ === 'undefined'

// Example calling non existent function
safe(o,'nothing').style().testing.$ === 'nothing'
@prettydiff
Copy link

Would you mind providing a few text paragraphs explaining the goals of this project and the expected input/output. This sounds like a cool microapp, but I don't want to make any false assumptions about what it does versus what it wants to do.

@rauschma
Copy link

I’m not sure I would include methods, but it’s a nice idea for navigating JSON-compatible data.

@d-akara
Copy link
Author

d-akara commented Feb 18, 2016

I decided to include methods to demonstrate that is an option if someone wanted that behavior and this was somewhat a fun experiment on my part to introduce myself to Proxies.

@robert-rose
Copy link

robert-rose commented Jan 7, 2018

Looks like isResolved logic can be simplified given the constraints on target made in the preceding line.
target = target || defaultValue;
target = {[nonNavigableTarget]: {target: target, isResolved: !target || target === defaultValue}};

Also, would be good to leave 0 and false as valid property values rather than changing them to the defaultValue.

So making both these changes would be:
target = (typeof target === "undefined" || target === null) ? defaultValue : target;
target = {[nonNavigableTarget]: {target: target, isResolved: target === defaultValue}};

Although I wonder if it should just be isResolved: true.

@olivierr91
Copy link

olivierr91 commented May 8, 2018

Function calls do not work if one of the item in the chain before the function call is null or undefined.
Example: safe(undefined).toString().$ results in toString is not a function error. Any idea on how to fix this?

@d-akara
Copy link
Author

d-akara commented Apr 29, 2019

@orobert91 FYI, made a module that includes this functionality.
https://www.npmjs.com/package/safe-objects

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment