Blog

Luis Majano

June 03, 2026

Spread the word


Share your thoughts

Every application eventually has to deal with deeply nested data. JSON API responses with payloads six levels deep. Configuration files where the key you need is buried inside an array of objects, one of which has a null for the field you thought was required. Module metadata structures that nobody wrote a schema for. Runtime introspection data shaped like a tree that grew without a plan.

BoxLang introduced DataNavigators in version 1 so you can use dataNavigate(), call .from() to scope down, chain your .get() calls, provide defaults everywhere. Clean and safe. But even with the fluent API, extracting a specific slice of a complex payload still required multiple navigator hops, or a loop, or defensive key-exists checks stacked three levels deep.

BoxLang 1.14.0 changes that. The DataNavigator now understands JSONPath-style path expressions natively, and a new query() method lets you fan out across a structure and collect every match in a single call. Less ceremony. More signal.


A Quick Refresher: What is the DataNavigator?

The DataNavigator is BoxLang's fluent helper for safely moving through nested data structures: Structs, Arrays, parsed JSON, configuration documents, runtime metadata. The key feature is that it never throws when a path doesn't exist -- it returns a null, a default, or an empty navigator depending on how you call it.

// The old way -- three levels of structKeyExists
if ( structKeyExists( config, "database" ) ) {
    if ( structKeyExists( config.database, "connection" ) ) {
        maxSize = config.database.connection.pool?.maxSize ?: 10
    }
}

// With DataNavigator
maxSize = dataNavigate( config ).get( ["database", "connection", "pool", "maxSize"], 10 )

You create a navigator with the dataNavigate() BIF, which accepts a Struct, a JSON string, a file path to a JSON file, or a Java Map. From there, you use .from() to scope, .has() to check, .get() / .getOrThrow() to extract.

That API still works exactly as it always did. What 1.14.0 adds is a compact expression language that you can drop directly into those same methods.


What Is New in 1.14.0

Four focused additions to the DataNavigator:

+------------------------------------------+
|  DataNavigator  -- 1.14.0 additions      |
|                                          |
|  get( "dot.path[0]" )    <-- JSONPath    |
|  has( "..recursive" )    <-- JSONPath    |
|  from( "dot.path" )      <-- JSONPath    |
|                                          |
|  query( "path[*].key" )  <-- NEW: multi  |
|  getOrDefault( key, v )  <-- NEW: safe   |
|  getByKey( "x.y" )       <-- NEW: exact  |
|  hasByKey( "x.y" )       <-- NEW: exact  |
+------------------------------------------+
  • JSONPath expressions in get(), has(), from() -- when you pass a single string containing . or [, it is treated as a path expression. Multi-argument and plain key calls behave exactly as before.
  • query(path) -- returns every match as a BoxLang Array. Wildcards, slices, filters, and recursive descent can produce multiple matches; query() collects them all.
  • getOrDefault(key, value) -- an explicit-fallback variant of get(). Guaranteed non-null. Cleaner than the null-check pattern.
  • getByKey(key) / hasByKey(key) -- exact key lookup where dots and brackets are literal, not separators. For payloads that have keys like "db.host" as an actual key name.

The Path Expression Syntax

BoxLang's JSONPath dialect covers the operations that matter day-to-day. Here is the full reference:

Path Expression Syntax
======================

  boxlang.settings.hello       Dot notation: navigate nested struct keys
  keywords[1]                  Array index: 1-based, returns that element
  ..hello                      Recursive descent: first match anywhere in tree
  items[*].name                Wildcard: all elements, then .name on each
  keywords[1:3]                Slice: 1-based inclusive range
  keywords[3:]                 Open-ended slice: index 3 to end
  items[?(@.active == true)]   Filter: elements matching a condition
  items[?(@.priority > 2)]     Filter: numeric comparison
  items[?(@.active && @.n>2)]  Filter: combined AND condition
  items[?(@.email)]            Filter: existence check (truthy field)
  items[?(@.active)].name      Filter + key: extract field from matches

Whitespace is tolerated anywhere:
  "boxlang . settings . hello"   == "boxlang.settings.hello"
  "keywords [ * ]"               == "keywords[*]"

Trigger rule:
  Single string with "." or "[" -> path expression
  Multiple arguments or no separators -> original variadic key behavior

All of this syntax is available inside get(), has(), from(), and query().


Real-World Scenarios

Scenario 1: Processing an API Response

You are consuming a third-party REST API. The payload looks like this:

{
  "status": "ok",
  "data": {
    "store": {
      "products": [
        { "id": 1, "name": "Keyboard", "price": 149.99, "inStock": true,  "category": "hardware" },
        { "id": 2, "name": "Mouse",    "price": 49.99,  "inStock": false, "category": "hardware" },
        { "id": 3, "name": "License",  "price": 299.00, "inStock": true,  "category": "software" },
        { "id": 4, "name": "Cable",    "price": 9.99,   "inStock": true,  "category": "hardware" }
      ]
    }
  }
}

Before 1.14.0, pulling in-stock product names required navigating to the array and then looping:

// Pre-1.14.0
products = dataNavigate( apiResponse )
    .from( ["data", "store", "products"] )
    .get( [] )

inStockNames = products
    .filter( p -> p.inStock )
    .map( p -> p.name )

With JSONPath expressions in 1.14.0, the navigator handles the traversal and filter in a single call:

nav = dataNavigate( apiResponse )

// All in-stock product names in one expression
names = nav.query( "data.store.products[?(@.inStock == true)].name" )
// => [ "Keyboard", "License", "Cable" ]

// Only hardware items over $50
expensive = nav.query( "data.store.products[?(@.category == 'hardware' && @.price > 50)]" )
// => [ { id:1, name:"Keyboard", price:149.99, ... } ]

// First in-stock product (single result)
first = nav.get( "data.store.products[?(@.inStock == true)]" )
// => { id:1, name:"Keyboard", ... }

// Total product count (safe, with default)
count = nav.getOrDefault( "data.store.meta.count", 0 )
// => 0 (field doesn't exist, default returned cleanly)

Note the distinction: get() returns the first match. query() returns all matches as an Array.

Scenario 2: Configuration Introspection

You are writing a module that needs to inspect the runtime's boxlang.json and extract settings across nested paths. Some keys may or may not be present depending on the deployment environment.

config = dataNavigate( server.system.config )

// Dot-path reads -- no chained .from() calls needed
logLevel   = config.getOrDefault( "logging.level", "WARN" )
datasource = config.getOrDefault( "defaultDatasource", "main" )
cacheMax   = config.getOrDefault( "caches.default.maxObjects", 1000 )

// Recursive descent -- find any "timeout" key anywhere in the config tree
// useful when you don't know the exact nesting
timeout = config.get( "..timeout", 30 )

// Existence check before consuming an optional section
if ( config.has( "modules.bx-ai.providers[*]" ) ) {
    providers = config.query( "modules.bx-ai.providers[*].name" )
    // => [ "openai", "anthropic", "bedrock" ]
}

// Keys that literally contain dots (common in Java-style property files)
// Use getByKey() to skip path parsing entirely
jdbcUrl = config.getByKey( "datasources.main.db.url" )
//                          ^ treated as ONE literal key, not a path

The getByKey() / hasByKey() distinction matters whenever your data was shaped by a Java properties system, a dotted-key config library, or any payload where a key name contains . or [ as real characters.

Scenario 3: Wildcard and Slice Extraction

You have a module's metadata struct and want to extract specific slices for a dashboard or diagnostic tool.

moduleData = {
    "name": "bx-ai",
    "version": "3.2.0",
    "providers": [
        { "name": "openai",    "models": [ "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo" ] },
        { "name": "anthropic", "models": [ "claude-opus-4-5", "claude-sonnet-4-5" ] },
        { "name": "bedrock",   "models": [ "titan", "llama3" ] }
    ],
    "settings": {
        "timeout": 30,
        "retries": 3,
        "debug": false
    }
}

nav = dataNavigate( moduleData )

// All provider names
nav.query( "providers[*].name" )
// => [ "openai", "anthropic", "bedrock" ]

// First two providers only (slice)
nav.query( "providers[1:2]" )
// => [ { name:"openai", ... }, { name:"anthropic", ... } ]

// All model names across all providers -- wildcard + wildcard
nav.query( "providers[*].models[*]" )
// => [ "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo", "claude-opus-4-5", ... ]

// All settings values (wildcard on struct)
nav.query( "settings.*" )
// => [ 30, 3, false ]

// Providers that have more than 2 models
nav.query( "providers[?(@.models)]" )
// => all providers that have a models field (existence check)

Choosing the Right Method

The four retrieval methods serve distinct purposes. Use this as a guide:

+-----------------+----------------------------------------------+
| Method          | Use When                                     |
+-----------------+----------------------------------------------+
| get(path, dflt) | You expect one value; path may not exist;    |
|                 | returns null or default                      |
+-----------------+----------------------------------------------+
| getOrDefault(   | Same as get() but you want to guarantee a    |
|   path, value ) | non-null return with an explicit fallback    |
+-----------------+----------------------------------------------+
| getOrThrow(path)| Value is REQUIRED. Missing key is a fatal   |
|                 | configuration error; fail fast               |
+-----------------+----------------------------------------------+
| query(path)     | You expect MULTIPLE matches: wildcards,      |
|                 | slices, filters that fan out; always Array   |
+-----------------+----------------------------------------------+
| getByKey(key)   | Key name literally contains "." or "[";      |
|                 | you want exact lookup, no path parsing       |
+-----------------+----------------------------------------------+

One rule of thumb: if your path expression contains [*], [n:m], .., or a filter [?(...)], reach for query(). These operators can match zero, one, or many nodes. get() only returns the first hit and silently drops the rest.


Putting It All Together

Here is a complete, realistic example: loading and validating a multi-environment application config, then extracting just what you need from each section.

class AppConfigLoader {

    function load( required string environment ) {
        var nav = dataNavigate( expandPath( "/config/app.json" ) )

        // Required fields -- fail fast if missing
        var appName = nav.getOrThrow( "app.name" )
        var appVersion = nav.getOrThrow( "app.version" )

        // Scope to the requested environment
        var envNav = nav.from( "environments.#environment#" )
        if ( envNav.isEmpty() ) {
            throw( type="ConfigError", message="Unknown environment: #environment#" )
        }

        // Gather database settings with defaults
        var db = {
            host:     envNav.getOrDefault( "database.host", "localhost" ),
            port:     envNav.getOrDefault( "database.port", 5432 ),
            ssl:      envNav.getOrDefault( "database.ssl", true ),
            poolMax:  envNav.getOrDefault( "database.pool.maxConnections", 10 )
        }

        // Collect all enabled feature flags
        var enabledFeatures = envNav.query( "features[?(@.enabled == true)].name" )

        // Find the first configured cache provider (recursive descent)
        var cacheProvider = envNav.get( "..cacheProvider", "default" )

        // Any module-level overrides -- wildcard across all modules
        var moduleOverrides = envNav.query( "modules[*].overrides" )

        return {
            name:             appName,
            version:          appVersion,
            environment:      environment,
            database:         db,
            enabledFeatures:  enabledFeatures,
            cacheProvider:    cacheProvider,
            moduleOverrides:  moduleOverrides
        }
    }

}

No loops. No null guard towers. No nested structKeyExists() chains. The path expressions describe the shape of the data you want, and the navigator handles the traversal.


Upgrade and Explore

JSONPath support in the DataNavigator ships in BoxLang 1.14.0, available today. No dependencies to add, no configuration to change. If you are already using dataNavigate(), the new expressions are a drop-in enhancement. Existing variadic-key calls are untouched.

Full documentation is at boxlang.ortusbooks.com/boxlang-language/syntax/data-navigators.

The complete 1.14.0 release notes, including Dynamic Sets, Ranges, Inner Classes, Query Transformers, and all 65 closed issues, are at boxlang.ortusbooks.com/readme/release-history/1.14.0.


Resources


BoxLang is a modern, multi-runtime JVM language by Ortus Solutions. Built for the web, the cloud, and the command line. Open source, professionally supported.

Add Your Comment

Recent Entries

Modernizing Web Applications and Expanding Practical AI around the World

Modernizing Web Applications and Expanding Practical AI around the World

In partnership with El Salvador’s Ministry of Foreign Affairs, Ortus Solutions created a space for business leaders, technology teams, universities, professionals, and developers to explore how modern tools can improve productivity, extend existing applications, and make AI adoption more practical

Maria Jose Herrera
Maria Jose Herrera
July 22, 2026
Building Native CLI Apps with MatchBox & Native Fusion

Building Native CLI Apps with MatchBox & Native Fusion

MatchBox makes BoxLang practical for a classic deployment target: the single-file command-line application.

The MatchBox open beta is available at https://github.com/ortus-boxlang/matchbox.

With the MatchBox native target, you can compile a .bxs script into a standalone executable for macOS, Linux, or Windows. The generated binary includes the MatchBox VM core and your compiled BoxLang bytecode. It does not require a JVM, a separate MatchBox install, or any runtime on the target machine.

Jacob Beers
Jacob Beers
July 14, 2026