Skip to content

07 · Package Development Basics

Every function you've written so far has lived in a single script, loaded with library() calls at the top and source() if it's spread across files. An R package is the standard way to bundle functions, documentation, and tests into something installable and shareable — this module builds a small real package from scratch: the file layout, roxygen2 documentation, and R CMD build/INSTALL/check.

Minimum package layout

mystats/
├── DESCRIPTION
├── NAMESPACE
├── R/
│   └── mean_ci.R
└── tests/
    ├── testthat.R
    └── testthat/
        └── test-mean_ci.R

DESCRIPTION is metadata (name, version, dependencies, license) as a plain key-value text file:

Package: mystats
Title: Small Statistics Helpers
Version: 0.1.0
Authors@R: person("Jane", "Doe", email = "jane@example.com", role = c("aut", "cre"))
Description: A minimal example package with a couple of statistics helpers.
License: MIT + file LICENSE
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.0
Suggests: testthat (>= 3.0.0)
Config/testthat/edition: 3

NAMESPACE declares which functions the package exposes to users (export(...)) and which functions it imports from other packages — you almost never hand-write this file; it's generated for you.

Documenting functions with roxygen2

#' Compute a confidence interval for a mean
#'
#' @param x A numeric vector.
#' @param conf Confidence level, default 0.95.
#' @return A named numeric vector with `lower` and `upper`.
#' @export
#' @examples
#' mean_ci(c(1, 2, 3, 4, 5))
mean_ci <- function(x, conf = 0.95) {
  x <- x[!is.na(x)]
  n <- length(x)
  se <- sd(x) / sqrt(n)
  alpha <- 1 - conf
  margin <- se * qt(1 - alpha / 2, df = n - 1)
  m <- mean(x)
  c(lower = m - margin, upper = m + margin)
}

The #' comment block directly above the function is not decoration — roxygen2::roxygenise() parses it to generate both the NAMESPACE export() entry (from @export) and a proper .Rd help file (from @param, @return, @examples):

roxygen2::roxygenise(".")
Writing 'NAMESPACE'
Writing 'mean_ci.Rd'
# NAMESPACE
# Generated by roxygen2: do not edit by hand
export(mean_ci)

The generated man/mean_ci.Rd is what powers ?mean_ci once the package is installed — write the roxygen block once, get both the namespace entry and the help page from it, rather than maintaining either by hand.

Building, installing, and checking

system("R CMD build mystats")
* checking for file 'mystats/DESCRIPTION' ... OK
* preparing 'mystats':
* checking DESCRIPTION meta-information ... OK
* building 'mystats_0.1.0.tar.gz'

R CMD build bundles the package directory into a single .tar.gz — the same format you'd submit to CRAN or hand to a colleague. R CMD INSTALL mystats_0.1.0.tar.gz then installs it into your R library so it can be loaded with a normal library(mystats) call, exactly like any CRAN package:

library(mystats)
mean_ci(c(1, 2, 3, 4, 5))
#    lower    upper
# 1.036757 4.963243

R CMD check (not run here, but standard practice before any real release) runs a much stricter battery of validations — documentation matches function signatures, examples actually run, NAMESPACE is consistent, no undeclared dependencies — catching problems that a plain build/INSTALL cycle would silently let through.

Tests live inside the package

# tests/testthat.R
library(testthat)
library(mystats)
test_check("mystats")
# tests/testthat/test-mean_ci.R
test_that("mean_ci returns lower < upper", {
  ci <- mean_ci(c(1, 2, 3, 4, 5))
  expect_lt(ci["lower"], ci["upper"])
})

test_that("mean_ci handles NA", {
  ci <- mean_ci(c(1, 2, NA, 4, 5))
  expect_false(any(is.na(ci)))
})
[ FAIL 0 | WARN 0 | SKIP 0 | PASS 2 ]

This is the same testthat machinery from Level 2 — the only difference inside a package is that tests live under tests/testthat/ by convention and are wired up by test_check(), which R CMD check runs automatically as part of validating the package.

The @export trap

Trap: a function defined in R/ but missing the @export roxygen tag is still usable inside the package's own code, but stays invisible to anyone who calls library(mystats)mean_ci without @export would exist in the package's internal namespace but raise "could not find function" for an external caller. This is deliberate (it's how you keep genuinely internal helper functions out of your package's public surface), but it's also the single most common reason "I wrote the function but users can't call it" bugs happen — check for a missing @export before anything more exotic.

Cheat sheet

Task Command
Generate NAMESPACE + docs from roxygen comments roxygen2::roxygenise(".")
Bundle the package into a distributable file R CMD build <pkg-dir>
Install the built package locally R CMD INSTALL <pkg>_<version>.tar.gz
Run the full CRAN-style validation suite R CMD check <pkg>_<version>.tar.gz
Make a function visible outside the package @export roxygen tag
Run the package's test suite testthat::test_dir("tests/testthat") or via R CMD check
View a function's generated help ?function_name (after install)

How It Actually Works

R CMD check, the gate CRAN and most CI pipelines run, doesn't just "look for errors" — it performs a fixed sequence of independent checks: parsing DESCRIPTION for valid metadata, byte-compiling every function in R/ to catch syntax errors, cross-referencing NAMESPACE exports against actual function names, running every example in your .Rd docs as real R code, executing your test suite, and (optionally) building the vignettes with Pandoc — each check is its own subprocess, which is why a single check failing (like an undocumented argument) doesn't prevent the others from running and reporting separately.

usethis::use_package("dplyr") doesn't install anything — it edits your DESCRIPTION's Imports: field, which only records a declared dependency; nothing forces you to actually call dplyr:: functions correctly until R CMD check tries to load your namespace and verifies every function referenced via pkg::fun() or @importFrom actually exists in that dependency's installed namespace. This static cross-referencing (not a runtime check) is exactly why a typo'd function name from an imported package is caught at check time rather than only when a user happens to trigger that code path.

Exercise

  1. Create a minimal package with one exported function of your own, document it with roxygen2 (@param, @return, @examples), run roxygen2::roxygenise("."), and confirm both NAMESPACE and a .Rd file under man/ are generated correctly.
  2. Add a second, un-exported helper function your exported function calls internally — confirm it works from inside the package but is not visible after library() from outside.
  3. Add a tests/testthat/ test for your exported function, install the package, and run test_dir() against it to confirm it passes.