Skip to content

btraven00/tinyoptics

Repository files navigation

tinyoptics: Data Manipulation for Modern R

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.

Why tinyoptics?

  • Bidirectional: Don't just extract data; reach in and change it. Every optic knows how to view, set, and over.
  • Safe: Built on a formal Maybe (Just/Nothing) system. No more if(is.null(x)) checks.
  • Composable: Chain paths together with the %.% operator.
  • S7 Native: Built for the future of R's object system.

Quick Start

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) 

The Optics Hierarchy

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

Installation

# Install from GitHub
# install.packages("remotes")
remotes::install_github("btraven00/tinyoptics")

Core Concepts

Lenses: Focus on Exactly One Thing

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: Maybe There, Maybe Not

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)
# NULL

Traversals: Focus on Multiple Things

Traversals 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: Track Position While You Transform

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 "Path" Power

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")

Real-World Example

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)

Composition: Where Optics really shine

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)

Built-in Optics

Lenses

  • at(key) - Focus on a specific key/index
  • at_path(path) - Focus using dot notation (e.g., "user.profile.name")
  • id_iso - Identity isomorphism

Path Helpers

  • traverse_path(path) - Traversal using dot notation with wildcards (e.g., "data.users.*.email")

Prisms

  • prism_head - First element of a list
  • prism_last - Last element of a list
  • prism_1, prism_2, prism_3 - Nth element (1-indexed)
  • nth_p(n) - Nth element prism
  • filter_p(predicate) - Elements matching a predicate

Traversals

  • each_t - All elements in a list/vector
  • filter_t(predicate) - Elements matching a predicate
  • cols_t(names) - Specific columns in a data frame
  • vars_t(predicate) - Columns matching a predicate
  • where_t(logic_vec) - Rows based on logical vector
  • leaves_t - All leaf nodes in nested structure
  • find_key_t(key) - Recursive key search
  • select_t(predicate) - 0 or 1 element matching predicate

Indexed Traversals

  • each_i - All elements with their indices/names
  • cols_i(names) - Specific columns with column names
  • vars_i(predicate) - Columns matching predicate with names
  • filter_i(predicate) - Filtered elements with indices

Core Operations

All operations use data-first argument order for natural piping:

  • data |> view(optic) - Extract a value
  • data |> set(optic, value) - Set a value
  • data |> over(optic, fn) - Transform a value with fn(value)
  • data |> over_i(optic, fn) - Transform with fn(index, value)
  • data |> preview(optic) - Get a boxed Maybe value
  • data |> extract_all(traversal) - Get all targeted values
  • data |> extract_all_i(traversal) - Get all values with their indices

Safety and Graceful Failure

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 unchanged

Examples

See the examples/ directory for more detailed examples:

  • at_path_examples.R - Comprehensive path navigation examples
  • indexed_optics_examples.R - Indexed optics with real-world use cases

License

GPL-3

Related Projects

  • S7 - Modern object-oriented programming for R

About

Data Manipulation for Modern R

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors