New behooveful features for {box} modules

Other ways to write {box} modules

Packaging, lazy imports, and native code for {box} modules.
R
box
programming
modules
packages
Author

Joshua Marie

Published

October 12, 2026

1 Rationale

R keeps growing, and its ecosystem is a big part of why. A talented community keeps writing R code that improves the language for everyone else, and it ships that code as R packages. Reusable code can be organized in a few ways, but I am not fond of the native options. I even ranked the ways to import R packages in an earlier post, with the native functionality at the bottom of the list.

First, library(). It attaches a package’s exported names to the search path, which is not the same as importing them into a local scope. The attached package:dplyr environment sits right after the global environment, so code anywhere in your session sees those names. This causes collisions: attaching dplyr masks filter() and lag() from {stats}. You also cannot confine an attach to a function. Take this example:

cv_data = function(data, ...) {
    library(dplyr)
    library(tidyr)
    library(tidyselect)

    data |>
        select(...) |>
        select(where(is.numeric)) |>
        pivot_longer(
            cols = everything(),
            names_to = "var",
            values_to = "val"
        ) |>
        group_by(var) |>
        summarise(
            m = mean(val, na.rm = TRUE),
            sd = sd(value, na.rm = TRUE),
            cv = (sd / m) * 100,
            # .by = var
        )
}

iris |> cv_data(everything()) works, but it also leaves dplyr and tidyr attached for the rest of the session, so the imports leak out of the function. The native :: operator (dplyr::select()) avoids the attach, but it gets noisy in pipelines and gives you no aliasing or selective import. box::use(dplyr[select, group_by]) gives you both.

Then there is source(), the native way to reuse a local script. By default, it evaluates in the global environment (local = FALSE). With local = TRUE it evaluates in the calling environment instead, but you still get no namespace, no explicit import list, and no way to tell where a name came from. Neither library() nor source() makes R modular.

Second, little in native R is ergonomic for writing and organizing code. R has the package system, but here are the shortcomings I have observed:

  1. Traditional R packages do not support hierarchical nesting of code. The source container is the flat R/ directory, an awkward constraint (Disclaimer: in my opinion) when you come from Python, where folders and scripts map directly onto nestable packages and modules.

  2. They need boilerplate: DESCRIPTION, NAMESPACE, and a formal package structure, even for a small reusable unit. usethis and roxygen2 generate most of it, so this is not a huge deal, but it is still friction.

  3. Source files in a CRAN-style package are evaluated into one shared package namespace. The order follows the Collate field in DESCRIPTION, and without it, files are evaluated by the lexicographical/alphabetical order of the file name (see the roxygen2 article on collation). This gets more annoying as the code grows. By contrast, each box module lives in its own isolated namespace and is loaded with box::use(), not sourced into the calling environment.

2 “Futuristic” Aspects

I have been contributing to box lately, as I am aiming to the future for a better way to write, organize, and reuse R code for the past years. Over the past 8 months I built {baler}, a CLI tool written in Rust, to package and distribute box modules. There is no formal name for these packages, so I call them box-bale modules for now. I am biased, but I recommend writing packages this way. I won’t list every reason here (the official documentation does), but in a nutshell, its logic and interface resemble pip in Python: it takes versioning seriously, and you can lock the dependency structure of box-bale modules.

I wrote {baler} to give box modules a path to being packaged the way Python modules are, and I also contributed to box itself. There I proposed lazy imports through a ~ prefix on the leftmost side of a declared import. The idea comes from PEP 810, which shipped in Python 3.15 in October 2026 with a lazy import keyword. Since box::use() is an ordinary R function call, a keyword is not an option, so ~ is the R adaptation. I’ll discuss it later.

These new features in box are not just something I want. I need them (as the title suggests).

SpongeBob meme: I need it

3 Making packages out of them

Imagine you are in Python or Julia. With baler, you can now turn box modules into packages, with a procedure close to Python’s. Yes, assuming you are on the existing directory, run one of the following:

  1. Without a source code directory:

    baler init
  2. With an existing source code directory:

    baler init --src <directory-name>

baler init always writes the metadata file, baler.toml, which is similar to DESCRIPTION in a CRAN-style R package.

3.1 Declaring dependencies

baler.toml is where a module declares what it needs: R packages in [dependencies], and other modules in [dependencies.baler]. Version constraints use the same style you would see in pip or Cargo.

[dependencies]
dplyr = ">=1.1.0"
tidyr = "*"

3.2 Installing modules

There are few ways to install box-bale module. First, open this to let me warn you:

You can install baler install <name-of-the-module>, an ordinary way. The registry for box-bale modules, however, its infrastructure haven’t built yet. Thus, stay with the alternative ways to be discussed below.

  1. You want to install the module within the path:

    baler install --path .
    baler install --path <name-of-the-module>_<version>.tar.gz
    baler install --path . --install-deps
  2. Installing the source from GitHub

    baler install --git <GitHub URL> 
    baler install --git <GitHub URL> --branch <Git Branch>
    baler install --git <GitHub URL> --tag <GitHub tag>
    baler install --git <GitHub URL> --module-dir <dir>
    baler install --git <GitHub URL> --module-dir <dir> --tag <GitHub tag>
  3. Installing the archived tarball directly in a URL

    baler install --url <url.tar.gaz>

An installed module behaves like a package, so you load it with box::use() as usual:

box::use(module_name[some_function])

3.3 Locking dependencies

Like pip freeze or uv lock, {baler} can record the exact resolved dependency tree in baler.lock. Locking is opt-in by presence: if baler.lock exists, it is used, otherwise will rely on the baler.toml. Record the dependency tree by running the following, assuming you are in a current directory .:

baler lock                      # No need to place `.` since that's the value by default
baler lock --with-rver          # If you wanna record R version you are working in
baler lock --update             # Updates the lockfile and re-resolves the dependency tree

Commit baler.lock to version control, so everyone on the project gets the same dependency structure.

4 Lazy Imports

By default, box::use() loads everything it is given at the moment it runs. If a module imports a heavy package that only one rarely used function needs, you pay the loading cost on every run, even when that function is never called. Lazy imports defer that cost until the name is first used.

4.1 The ~ prefix

Put ~ in front of an import to make it lazy. Imports without it behave as before:

box::use(
    ~ dplyr,
    tidyr
)

Here dplyr is only loaded when something from it is first accessed, while tidyr is loaded immediately. This is the same idea as the lazy keyword in Python 3.15:

lazy import json
import os

The prefix works on everything you can pass to box::use(): packages, local modules, submodules, and modules installed with {baler}.

4.2 Toy Example

Let’s start with a toy example…

4.3 Rules

~ is a marker on a whole import declaration, not an operator. It is valid only as a single, outermost prefix:

# Valid
box::use(~ dplyr)
box::use(~ dplyr[select, mutate])
box::use(~ ./utils)
box::use(~ ./utils[fn])

# Invalid
box::use(~~dplyr)
box::use(./a/~b)
box::use(a ~ b)
box::use(~ dplyr[...])

The last line is rejected too: a lazy import cannot use the [...] wildcard, because attaching every export requires loading the package to know what they are. Python has the same restriction for lazy from module import *.

4.4 Trade-offs

Lazy imports are not free:

  • Errors move. A missing or broken package fails at first use, not at box::use().
  • Side effects move. Anything a package does when it loads, such as setting options, happens later.
  • Packages you always need gain nothing. Keep those eager.

4.5 Try it

The lazy imports pull request has been submitted to box and is awaiting merge. Until then, you can try it from my fork:

# install.packages('pak')
pak::pak("joshuamarie/box@feature/lazy-imports")

5 Not exactly new but astral

Not exactly new, but worth showing with {baler} and box together: three things that other languages made routine, now available for R modules.

5.1 Writing native compiled code

A module can ship compiled code, such as Rcpp. With {baler}, you declare it in baler.toml, and baler install builds and caches the compiled code automatically, with no manual steps:

[compiled-code]
# path = ["src/"]
binary = true
keep_source = true

[compiled-code.build-deps]
Rcpp = ">=1.1.0"
RcppEigen = "*"

build-deps lists the packages needed to compile the code. Since the build is cached, users do not recompile on every install. As with any Rcpp code, a compiler toolchain is still needed on the machine that builds it (Rtools on Windows).

5.2 The if __name__ == '__main__' idiom, but for {box}

In Python, this idiom lets a file act as both an importable module and a runnable script:

def main():
    print("running")

if __name__ == "__main__":
    main()

box has an equivalent. box::name() returns NULL when a file runs as a script, and the module name when it is imported:

box::use(./utils[clean])

main = function() {
    clean()
}

if (is.null(box::name())) {
    main()
}

Run it with Rscript path/to/file.r and main() executes. Import it from another module with box::use(./file[main]) and nothing runs until you call it.

5.3 Testing modules

Tests are plain testthat files that import the module under test with box::use():

box::use(
    ../R/stats_utils[cv]
)

test_that("cv is zero for constant input", {
    expect_equal(cv(c(2, 2, 2)), 0)
})

Run it with testthat::test_file(). If you edit a module during a session, box::reload() picks up the change without restarting R.

6 Conclusion

References