The goal of stable is to provide type stability for R objects through a flexible rule system. It allows you to create data structures that enforce constraints on modifications, ensuring type safety and preventing unwanted changes to your data.
You can install the development version of stable from GitHub:
# install.packages("devtools")
devtools::install_github("moodymudskipper/stable")Here are some examples showing how to use stable to create type-safe data structures:
library(stable)
# Create a stable tibble with default rules, explicited here for convenience
x <- stable_tibble(
a = 1,
b = "z",
.rules = c(
rule_bounded(), # can't expand with new elements
everything() ~ rule_required(), # can't remove elements
everything() ~ rule_stable_ptype() # elements must respect their prototype
)
)
# These operations will fail due to the rules:
x$c <- 3 # Can't add new column (bounded)
#> Error in `rule$rule()`:
#> ! Can't create element `c`, the object is bounded.
x$b <- 1 # Can't change type (stable ptype)
#> Error in `rule$rule()`:
#> ! `new[nms]` must be a vector with type:
#>
#> <stable<
#> a: double
#> b: character
#> >>
#>
#> Instead, it has type:
#>
#> <stable<
#> a: double
#> b: double
#> >>
x$b <- NULL # Can't remove column (required)
#> Error in `rule$rule()`:
#> ! Can't remove element `b`, it is required.
# This works - same type
x$a <- 2
x
#> # A tibble: 1 × 2
#> a b
#> <dbl> <chr>
#> 1 2 zYou can create custom rule combinations for different use cases:
# Atomic elements only, but flexible structure
x <- stable_tibble(
a = 1,
b = "z",
.rules = everything() ~ rule_predicate(is.atomic)
)
x$c <- 3 # Works - atomic
x$d <- list(4) # Fails - not atomic
#> Error in `rule$rule()`:
#> ! The object must satisfy to `is.atomic` at d
# Type casting for compatible types
x <- stable_tibble(
a = 1.0,
b = "z",
.rules = where(is.double) ~ rule_cast()
)
x$a <- 2L # Integer gets cast to double
x
#> # A tibble: 1 × 1
#> a
#> * <dbl>
#> 1 2Stable objects work seamlessly with dplyr and base operations (provided methods are implemented in the package!):
x <- stable_tibble(a = 1, b = "z")
# This works
x |> dplyr::mutate(a = 2)
#> # A tibble: 1 × 2
#> a b
#> <dbl> <chr>
#> 1 2 z
# This fails (trying to remove required column)
x |> dplyr::mutate(b = NULL)
#> Error in `rule$rule()`:
#> ! Can't remove element `b`, it is required.
x |> transform(b = NULL)
#> Error in `rule$rule()`:
#> ! Can't remove element `b`, it is required.rule_bounded(): Prevents adding new elementsrule_required(): Prevents removing elements (requires selection)rule_immutable(): Prevents modifying elements (requires selection)rule_stable_ptype(): Ensures elements maintain their vector prototyperule_predicate(): Applies custom validation functionsrule_cast(): Automatically casts compatible types
Rules can be applied to specific elements using tidyselect syntax:
everything() ~ rule_required(): Apply to all columnswhere(is.numeric) ~ rule_immutable(): Apply to numeric columns onlyc(col1, col2) ~ rule_predicate(is.positive): Apply to specific columns
We can nest stable objects, though it’s not always useful.
# we don't need nested tibble objects
x <- stable_tibble(
a =1,
b = tibble(c = 3, d = 4),
)
x$b$c <- "a"
# but they don't hurt
x <- stable_tibble(
a =1,
b = stable_tibble(c = 3, d = 4),
)
x$b$c <- "a"
x$b$c <- 2
In the latter case we have a redundancy of checks but we can subset a stable object. We might argue that subsetting a stable object should produce a stable object by fetching the appropriate rules. This might be feasible.