View Source Nested (Nestex v0.2.0)
Nested provides utilities for accessing, extracting, and manipulating nested data structures in Elixir maps and lists with ease and safety.
This module is particularly useful when working with:
- JSON-like data structures
- Configuration maps
- API responses with nested data
- Complex data transformations
Key Features
- Safe navigation: Gracefully handles missing paths without raising exceptions
- Flexible path syntax: Supports integers, atoms, maps for filtering, and functions
- Map filtering: Find list items by matching specific criteria
- Deep traversal: Transform entire data structures with custom logic
- Struct handling: Automatically converts structs while preserving special types
Path Syntax
Paths are lists that can contain:
integer
- List index or map keyatom
- Map key or keyword list keystring
- Map string keymap
- Filter criteria for finding items in listsfunction/1
- Custom predicate for finding items in lists"*"
- Wildcard to match all items (extract only)
Examples
Basic usage with nested maps and lists:
data = %{
users: [
%{name: "Alice", age: 30, active: true},
%{name: "Bob", age: 25, active: false}
],
config: %{database: %{host: "localhost", port: 5432}}
}
# Simple path navigation
Nested.get(data, [:users, 0, :name])
#=> "Alice"
# Map filtering - find user by criteria
Nested.get(data, [:users, %{active: true}, :name])
#=> "Alice"
# Function filtering - find user with custom logic
Nested.get(data, [:users, &(&1.age > 26), :name])
#=> "Alice"
# Extract all matching values
Nested.extract(data, [:users, "*", :name])
#=> ["Alice", "Bob"]
Query String Syntax
For convenience, you can also use query strings with Nested.key/2
:
# Basic dot notation
Nested.get(data, Nested.key("users.0.name"))
#=> "Alice"
# Bracket filtering
Nested.get(data, Nested.key("users[active=true].name"))
#=> "Alice"
# Extract with wildcards
Nested.extract(data, Nested.key("users[*].name"))
#=> ["Alice", "Bob"]
# String keys for JSON data
Nested.get(json_data, Nested.key("users.0.name", :string))
Summary
Functions
Replace values with "***"
for keys that contain any of the specified keywords.
Remove all nil
values from nested maps and lists.
Extract multiple values from nested data structures using pattern matching and wildcards.
Query data nested in maps and lists, returning {:ok, value}
or :error
.
Safely query data nested in maps and lists, returning a default value if the path doesn't exist.
Parse a query string into a path list for use with other Nested functions.
Deeply merge two maps, recursively combining nested maps.
Convert structs to maps by removing internal Elixir metadata.
Traverse nested data structures and apply transformations to each element.
Functions
Replace values with "***"
for keys that contain any of the specified keywords.
This function is useful for sanitizing sensitive data in logs, API responses, or when displaying data to users who shouldn't see certain fields.
Parameters
object
- The data structure to censorkeywords
- List of strings to match against key names (case-sensitive, partial matching)
Examples
user_data = %{
username: "alice",
email: "alice@example.com",
password: "secret123",
api_token: "abc123xyz",
user_preferences: %{theme: "dark"}
}
# Censor password-related fields
Nested.censor(user_data, ["pass", "token", "secret"])
#=> %{
username: "alice",
email: "alice@example.com",
password: "***",
api_token: "***",
user_preferences: %{theme: "dark"}
}
# Censor nested sensitive data
api_response = %{
users: [
%{name: "Alice", social_security: "123-45-6789", email: "alice@example.com"},
%{name: "Bob", social_security: "987-65-4321", email: "bob@example.com"}
],
admin_password: "supersecret"
}
Nested.censor(api_response, ["social", "password"])
#=> %{
users: [
%{name: "Alice", social_security: "***", email: "alice@example.com"},
%{name: "Bob", social_security: "***", email: "bob@example.com"}
],
admin_password: "***"
}
Matching Behavior
- Matching is case-sensitive
- Uses partial matching (substring search)
- Checks against the string representation of keys
- Works with atom keys, string keys, and any key type that can be converted to string
Use Cases
- Sanitizing logs before writing to files
- Preparing data for display in admin interfaces
- Removing sensitive data from error reports
- Creating safe data dumps for debugging
Remove all nil
values from nested maps and lists.
This function is useful for cleaning up data structures that may contain
unwanted nil
values, often resulting from API responses or incomplete data.
Examples
# Clean map with nil values
data = %{
name: "Alice",
email: nil,
profile: %{
age: 30,
bio: nil,
settings: %{theme: "dark", notifications: nil}
}
}
Nested.clean_nil(data)
#=> %{
name: "Alice",
profile: %{
age: 30,
settings: %{theme: "dark"}
}
}
# Clean list with nil values
users = [
%{name: "Alice", email: "alice@example.com"},
nil,
%{name: "Bob", email: nil}
]
Nested.clean_nil(users)
#=> [
%{name: "Alice", email: "alice@example.com"},
%{name: "Bob"}
]
Use Cases
- Cleaning API response data before storage
- Preparing data for JSON serialization
- Removing optional fields that weren't provided
- Database record cleanup
Extract multiple values from nested data structures using pattern matching and wildcards.
This function is powerful for collecting data from multiple locations in complex structures.
Unlike get/3
and fetch/2
which return single values, extract/2
always returns a list
of all matching values.
Parameters
object
- The data structure to query (map or list)paths
- List of keys/indices/filters, may include wildcards ("*"
)
Returns
Always returns a list, empty if no matches found.
Wildcard Usage
Use "*"
to match all items in a list:
data = %{
teams: [
%{name: "Backend", members: ["Alice", "Bob"]},
%{name: "Frontend", members: ["Charlie", "Diana"]}
]
}
# Get all team names
Nested.extract(data, [:teams, "*", :name])
#=> ["Backend", "Frontend"]
# Get all member lists
Nested.extract(data, [:teams, "*", :members])
#=> [["Alice", "Bob"], ["Charlie", "Diana"]]
Filtering Examples
users = [
%{name: "Alice", department: "Engineering", active: true},
%{name: "Bob", department: "Sales", active: false},
%{name: "Charlie", department: "Engineering", active: true}
]
data = %{users: users}
# Extract all user names
Nested.extract(data, [:users, "*", :name])
#=> ["Alice", "Bob", "Charlie"]
# Extract names of active users only
Nested.extract(data, [:users, %{active: true}, :name])
#=> ["Alice", "Charlie"]
# Extract names of engineering department
Nested.extract(data, [:users, %{department: "Engineering"}, :name])
#=> ["Alice", "Charlie"]
# Extract with function filtering
senior_users = fn user -> user.experience_years > 5 end
Nested.extract(data, [:users, senior_users, :name])
Complex Nested Extraction
company = %{
departments: [
%{
name: "Engineering",
teams: [
%{name: "Backend", skills: ["elixir", "postgres"]},
%{name: "Frontend", skills: ["react", "typescript"]}
]
},
%{
name: "Design",
teams: [
%{name: "UX", skills: ["figma", "research"]},
%{name: "Visual", skills: ["photoshop", "illustration"]}
]
}
]
}
# Get all team names across all departments
Nested.extract(company, [:departments, "*", :teams, "*", :name])
#=> ["Backend", "Frontend", "UX", "Visual"]
# Get all skills across all teams
Nested.extract(company, [:departments, "*", :teams, "*", :skills])
#=> [["elixir", "postgres"], ["react", "typescript"], ["figma", "research"], ["photoshop", "illustration"]]
Query data nested in maps and lists, returning {:ok, value}
or :error
.
This function provides explicit error handling for cases where you need to distinguish
between a successful lookup that returns nil
and a failed lookup.
Parameters
object
- The data structure to query (map or list)paths
- List of keys/indices/filters defining the path to the desired value
Returns
{:ok, value}
- If the path exists and leads to a value:error
- If the path doesn't exist or any step fails
Examples
data = %{
users: [
%{name: "Alice", email: nil},
%{name: "Bob", email: "bob@example.com"}
]
}
# Successful lookup
Nested.fetch(data, [:users, 0, :name])
#=> {:ok, "Alice"}
# Successful lookup returning nil
Nested.fetch(data, [:users, 0, :email])
#=> {:ok, nil}
# Failed lookup
Nested.fetch(data, [:users, 5, :name])
#=> :error
# Map filtering
Nested.fetch(data, [:users, %{email: "bob@example.com"}, :name])
#=> {:ok, "Bob"}
# No match found
Nested.fetch(data, [:users, %{email: "nonexistent@example.com"}, :name])
#=> :error
Advanced Filtering
Map Filtering
Find items in lists by matching map criteria:
users = [
%{name: "Alice", role: "admin", active: true},
%{name: "Bob", role: "user", active: false}
]
# Find admin user
Nested.fetch(%{users: users}, [:users, %{role: "admin"}, :name])
#=> {:ok, "Alice"}
# Find by multiple criteria
Nested.fetch(%{users: users}, [:users, %{role: "admin", active: true}, :name])
#=> {:ok, "Alice"}
Function Filtering
Use custom predicates for complex matching:
# Find user with specific condition
older_than_25 = fn user -> user.age > 25 end
Nested.fetch(data, [:users, older_than_25, :name])
Safely query data nested in maps and lists, returning a default value if the path doesn't exist.
This is the safest way to access nested data as it never raises exceptions.
Parameters
object
- The data structure to query (map or list)paths
- List of keys/indices/filters defining the path to the desired valuedefault
- Value to return if path doesn't exist (defaults tonil
)
Examples
data = %{
users: [
%{name: "Alice", skills: ["elixir", "js"]},
%{name: "Bob", skills: ["python"]}
]
}
# Basic access
Nested.get(data, [:users, 0, :name])
#=> "Alice"
# With custom default
Nested.get(data, [:users, 5, :name], "Unknown")
#=> "Unknown"
# Map filtering
Nested.get(data, [:users, %{name: "Bob"}, :skills])
#=> ["python"]
# Function filtering
has_elixir = fn user -> "elixir" in user.skills end
Nested.get(data, [:users, has_elixir, :name])
#=> "Alice"
# Keyword lists
config = [database: [host: "localhost", port: 5432]]
Nested.get(config, [:database, :host])
#=> "localhost"
Parse a query string into a path list for use with other Nested functions.
This function converts a human-readable query string into the path format
used by get/3
, fetch/2
, extract/2
, and other functions.
Parameters
query_string
- String representation of the pathkey_type
- Default type for dot notation keys (:atom
or:string
, defaults to:atom
)
Examples
# Basic usage
Nested.key("users.0.name")
#=> [:users, 0, :name]
# String keys mode
Nested.key("config.database.host", :string)
#=> ["config", "database", "host"]
# Use with other functions
path = Nested.key("users[active=true].0.name")
Nested.get(data, path)
For full syntax documentation, see Nested.QueryParser.parse/2
.
Deeply merge two maps, recursively combining nested maps.
When the same key exists in both maps:
- If both values are maps, they are merged recursively
- Otherwise, the value from the right map takes precedence
Parameters
left
- The base mapright
- The map to merge into the base map (takes precedence)
Examples
# Simple merge
config_defaults = %{
database: %{host: "localhost", port: 5432, pool_size: 10},
cache: %{enabled: false, ttl: 3600}
}
user_config = %{
database: %{host: "prod-db.example.com", pool_size: 20},
cache: %{enabled: true},
logging: %{level: :info}
}
Nested.merge(config_defaults, user_config)
#=> %{
database: %{host: "prod-db.example.com", port: 5432, pool_size: 20},
cache: %{enabled: true, ttl: 3600},
logging: %{level: :info}
}
Conflict Resolution
base = %{
settings: %{theme: "light", lang: "en"},
data: [1, 2, 3]
}
override = %{
settings: %{theme: "dark"}, # Map: merged recursively
data: [4, 5, 6] # Non-map: completely replaced
}
Nested.merge(base, override)
#=> %{
settings: %{theme: "dark", lang: "en"}, # Recursive merge preserves 'lang'
data: [4, 5, 6] # Complete replacement
}
Use Cases
- Configuration management with defaults and overrides
- Combining user preferences with system defaults
- Merging API response data
- Building composite data structures from multiple sources
Performance Notes
- Creates new data structures; doesn't modify originals
- Recursion depth matches the nesting depth of your data
- Efficient for typical configuration and data merging scenarios
Convert structs to maps by removing internal Elixir metadata.
This function recursively converts all structs in a data structure to plain maps while preserving special types like DateTime. It's useful for serialization, logging, or when you need plain data structures.
Examples
defmodule User do
defstruct [:name, :email, :created_at]
end
defmodule Company do
defstruct [:name, :users]
end
user1 = %User{name: "Alice", email: "alice@example.com", created_at: ~U[2023-01-01 00:00:00Z]}
user2 = %User{name: "Bob", email: "bob@example.com", created_at: ~U[2023-01-02 00:00:00Z]}
company = %Company{
name: "Tech Corp",
users: [user1, user2]
}
Nested.to_map(company)
#=> %{
name: "Tech Corp",
users: [
%{name: "Alice", email: "alice@example.com", created_at: ~U[2023-01-01 00:00:00Z]},
%{name: "Bob", email: "bob@example.com", created_at: ~U[2023-01-02 00:00:00Z]}
]
}
Preserved Types
These special types are NOT converted to maps:
Use Cases
- Preparing struct data for JSON serialization
- Logging structured data without internal metadata
- Converting Ecto schemas to plain maps
- API response formatting
Traverse nested data structures and apply transformations to each element.
This is the most powerful function in the module, allowing you to walk through and transform entire data structures with fine-grained control over the process.
Parameters
object
- The data structure to traverse (map, list, or struct)fun
- Function that receives each item and returns a transformation directive
Transformation Directives
The transformation function can return:
:discard
- Remove the item completely{:skip, item}
- Keep the item but don't traverse its children{:next, item}
- Transform the item and continue traversing its childrenitem
- Equivalent to{:next, item}
Examples
Basic Transformation
data = %{
name: "Product",
price: 100,
tags: ["electronics", "gadget"]
}
# Double all numeric values
Nested.traverse(data, fn
{key, value} when is_number(value) -> {key, value * 2}
item -> item
end)
#=> %{name: "Product", price: 200, tags: ["electronics", "gadget"]}
Removing Data
user_data = %{
name: "Alice",
email: "alice@example.com",
password: "secret123",
internal_id: "xyz789"
}
# Remove sensitive data
Nested.traverse(user_data, fn
{:password, _} -> :discard
{:internal_id, _} -> :discard
item -> item
end)
#=> %{name: "Alice", email: "alice@example.com"}
Complex Data Cleaning
api_response = %{
users: [
%{name: "Alice", temp_token: "abc", profile: %{age: 30, _debug: "info"}},
%{name: "Bob", temp_token: "def", profile: %{age: 25, _debug: "info"}}
],
_metadata: %{request_id: "123", debug: true}
}
# Remove temporary and debug data
Nested.traverse(api_response, fn
{:temp_token, _} -> :discard
{key, _} when is_atom(key) and String.starts_with?(Atom.to_string(key), "_") -> :discard
item -> item
end)
Conditional Processing
# Skip processing certain branches
Nested.traverse(data, fn
{:sensitive_data, value} -> {:skip, {:sensitive_data, value}} # Don't traverse children
{:public_data, value} -> {:next, {:public_data, process(value)}} # Transform and traverse
item -> item
end)
Struct Handling
The function automatically converts structs to maps (except for special types like DateTime):
defmodule User do
defstruct [:name, :email]
end
user = %User{name: "Alice", email: "alice@example.com"}
Nested.traverse(%{user: user}, fn item -> item end)
#=> %{user: %{name: "Alice", email: "alice@example.com"}}
Special types that are preserved:
Performance Notes
- The function creates new data structures; it doesn't modify in place
- For large datasets, consider processing in chunks or using streams
- Struct conversion has minimal overhead but creates new maps