tinyoptics is a functional-programming library for navigating and modifying complex nested R structures. Unlike the Tidyverse, which focuses on data flow, tinyoptics focuses on bi-directional structural paths.
- Bidirectional: Don't just extract data; reach in and change it. Every optic knows how to
view,set, andover. - Safe: Built on a formal Maybe (
Just/Nothing) system. No moreif(is.null(x))checks. - Composable: Chain paths together with the
%.%operator. - S7 Native: Built for the future of R's object system.
Imagine a deeply nested JSON-style list. You want to update a value buried deep inside, but only if the path exists and meets certain criteria.
library(tinyoptics)
# 1. Define a "surgical" path
# We want to target the first sample's value, but only if it's numeric
path <- at_path("metadata.samples.1") %.% filter_p(is.numeric)
# 2. Extract (View)
my_data |> view(path)
# [1] 42
# 3. Modify (Over)
# Increment the value by 10 without rebuilding the list manually
new_data <- my_data |> over(path, \(x) x + 10)
# 4. Safety
# If the path doesn't exist or isn't numeric, it returns the original object untouched!
"oops_not_a_list" |> set(path, 100) Choosing the right tool for the job is easy with the hierarchy.
| Optic | Targets | Can View? | Can Set? | Example |
|---|---|---|---|---|
| Iso | 1 (Reversible) | Yes | Yes | id_iso |
| Lens | 1 (Total) | Yes | Yes | at("id") |
| Prism | 0 or 1 | Yes | Yes | filter_p(is.numeric) |
| Optional | 0 or 1 | Yes | Yes | lens %.% prism |
| Traversal | 0 to N | List | Yes | each_t |
| IndexedTraversal | 0 to N | List | Yes | each_i |
# Install from GitHub
# install.packages("remotes")
remotes::install_github("btraven00/tinyoptics")Lenses provide guaranteed access to a single field or element.
# Access nested fields
user_name <- at("user") %.% at("name")
data |> view(user_name)
# [1] "Alice"
# Update nested values
new_data <- data |> set(user_name, "Bob")
# Transform values
new_data <- data |> over(user_name, toupper)Prisms handle optional values and pattern matching.
# Get first element (if exists)
first <- prism_head
list(1, 2, 3 |> view(first))
# [1] 1
list( |> view(first))
# NULL
# Filter with predicates
positive <- filter_p(function(x) x > 0)
5 |> view(positive)
# [1] 5
-3 |> view(positive)
# NULLTraversals let you work with multiple targets at once.
# Transform all elements
numbers <- list(1, 2, 3, 4, 5)
doubled <- numbers |> over(each_t, function(x) x * 2)
# list(2, 4, 6, 8, 10)
# Extract all values
numbers |> extract_all(each_t)
# list(1, 2, 3, 4, 5)
# Filter and transform
evens <- filter_t(function(x) x %% 2 == 0)
numbers |> over(evens, function(x) x * 10)
# list(1, 20, 3, 40, 5)Indexed traversals let you access both the index/key and the value during operations.
# Transform with awareness of position
data <- list(a = 10, b = 20, c = 30)
result <- value |> over_i(each_i, function(key) {
paste0(key, ":", value)
}, data)
# list(a = "a:10", b = "b:20", c = "c:30")
# Use numeric indices with unnamed lists
numbers <- list(5, 10, 15)
result <- val |> over_i(each_i, function(idx) {
val * as.numeric(idx) # Multiply by position
}, numbers)
# list(5, 20, 45) # 5*1, 10*2, 15*3
# Extract with indices
data |> extract_all_i(each_i)
# $a
# [1] 10
# $b
# [1] 20
# $c
# [1] 30
# Work with data frames by column name
df <- data.frame(x = 1:3, y = 4:6, z = 7:9)
result <- col_data |> over_i(vars_i(is.numeric), function(col_name) {
col_data * 10 # Could use col_name in the transformation
}, df)The at_path() function allows you to use familiar dot-notation while gaining the full power of S7 optics. It automatically converts strings into a chain of Lenses and Prisms.
# This:
at_path("a.b.1.c")
# Is equivalent to:
at("a") %.% at("b") %.% nth_p(1) %.% at("c")api_response <- list(
status = "success",
data = list(
users = list(
list(id = 1, name = "Alice", email = "alice@example.com"),
list(id = 2, name = "Bob", email = "bob@example.com")
)
)
)
# Access first user's email
email_lens <- at_path("data.users.1.email")
api_response |> view(email_lens)
# [1] "alice@example.com"
# Update it
new_response <- api_response |> set(email_lens, "newemail@example.com")
# Get ALL users' emails with wildcard traversal
email_traversal <- traverse_path("data.users.*.email")
api_response |> extract_all(email_traversal)
# list("alice@example.com", "bob@example.com")
# Update all emails at once
new_response <- api_response |> over(email_traversal, toupper)Combine optics with %.% to create complex data manipulations:
# Get all user ages and increment them
users <- list(
data = list(
list(name = "Alice", age = 30),
list(name = "Bob", age = 25),
list(name = "Charlie", age = 35)
)
)
age_traversal <- at("data") %.% each_t %.% at("age")
older_users <- users |> over(age_traversal, function(x) x + 1)at(key)- Focus on a specific key/indexat_path(path)- Focus using dot notation (e.g., "user.profile.name")id_iso- Identity isomorphism
traverse_path(path)- Traversal using dot notation with wildcards (e.g., "data.users.*.email")
prism_head- First element of a listprism_last- Last element of a listprism_1,prism_2,prism_3- Nth element (1-indexed)nth_p(n)- Nth element prismfilter_p(predicate)- Elements matching a predicate
each_t- All elements in a list/vectorfilter_t(predicate)- Elements matching a predicatecols_t(names)- Specific columns in a data framevars_t(predicate)- Columns matching a predicatewhere_t(logic_vec)- Rows based on logical vectorleaves_t- All leaf nodes in nested structurefind_key_t(key)- Recursive key searchselect_t(predicate)- 0 or 1 element matching predicate
each_i- All elements with their indices/namescols_i(names)- Specific columns with column namesvars_i(predicate)- Columns matching predicate with namesfilter_i(predicate)- Filtered elements with indices
All operations use data-first argument order for natural piping:
data |> view(optic)- Extract a valuedata |> set(optic, value)- Set a valuedata |> over(optic, fn)- Transform a value withfn(value)data |> over_i(optic, fn)- Transform withfn(index, value)data |> preview(optic)- Get a boxed Maybe valuedata |> extract_all(traversal)- Get all targeted valuesdata |> extract_all_i(traversal)- Get all values with their indices
tinyoptics uses a Maybe type system (Just/Nothing) to handle missing values gracefully:
maybe_value <- at_path("nonexistent.path")
data |> view(maybe_value)
# NULL (instead of error)
# Operations on failed paths leave data unchanged
data |> set(maybe_value, "value")
# Returns original data unchangedSee the examples/ directory for more detailed examples:
at_path_examples.R- Comprehensive path navigation examplesindexed_optics_examples.R- Indexed optics with real-world use cases
GPL-3
- S7 - Modern object-oriented programming for R