Package development
Lesson 11 — R Programming
Learning objectives
- Create a package skeleton and understand every file in it
- Document functions with
roxygen2and generate help pages - Manage dependencies through
DESCRIPTIONandNAMESPACE - Include data and vignettes
- Run
R CMD checkand interpret its output - Decide when a package is the right container for study code
Why a package
A package is not just for CRAN. It is R’s unit of shareable, testable, documented, versioned code. If your study has more than about five utility functions, a package is easier than a folder of scripts you source().
| Scripts | Package |
|---|---|
source("utils.R") and hope |
library(mystudy) |
| Comments as documentation | ?my_function |
| Tests you remember to run | devtools::test() on every change |
| Version = “whatever is on the share drive” | Version in DESCRIPTION, in Git |
| Dependencies implicit | Declared and checked |
| No installation check | R CMD check |
The pharmaverse is entirely packages — admiral, xportr, Tplyr — and company-internal standards code is increasingly packaged the same way.
Creating a package
usethis::create_package("~/dev/studyutils")This gives you:
studyutils/
├── DESCRIPTION # metadata and dependencies
├── NAMESPACE # what is exported and imported (generated)
├── R/ # all function definitions
└── studyutils.Rproj
Then set up the rest:
usethis::use_git()
usethis::use_mit_license() # or use_proprietary_license()
usethis::use_testthat()
usethis::use_readme_md()
usethis::use_news_md()
usethis::use_package_doc() # ?studyutils landing page
usethis::use_pkgdown() # documentation website
usethis::use_github_action("check-standard")DESCRIPTION
Package: studyutils
Title: Shared Derivations for Study ABC-101
Version: 0.3.0
Authors@R:
person("Ram", "Gaduputi", email = "ram@example.com",
role = c("aut", "cre"))
Description: Derivation functions, validation checks and table helpers used
across the ABC-101 analysis programs. Provides study-day calculation,
partial-date imputation and standard population flags.
License: MIT + file LICENSE
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.3
Depends:
R (>= 4.4)
Imports:
dplyr (>= 1.2.0),
rlang,
lubridate,
cli
Suggests:
testthat (>= 3.3.0),
knitr,
rmarkdown,
haven
Config/testthat/edition: 3
VignetteBuilder: knitr
URL: https://github.com/you/studyutils
BugReports: https://github.com/you/studyutils/issues
The dependency fields are frequently confused:
| Field | Meaning | Installed with your package? | Available via ::? |
|---|---|---|---|
Depends |
R version, or a package attached to the search path | Yes | Yes, and attached |
Imports |
Required, used internally | Yes | Yes, via :: |
Suggests |
Optional — tests, vignettes, examples | No | Only if user installs it |
Enhances |
Your package improves theirs | No | — |
Rules of thumb: put the R version in Depends; put everything your functions actually call in Imports; put test-only and vignette-only packages in Suggests. Do not put packages in Depends just to save typing :: — it pollutes the user’s search path.
Set minimum versions deliberately. For this course, the examples assume current training packages; in a production package, use the lowest R and package versions that provide the functions you actually call, then freeze exact versions in renv.lock.
usethis::use_package("dplyr") # -> Imports
usethis::use_package("testthat", "Suggests")
usethis::use_package("dplyr", min_version = "1.2.0")Version numbers
major.minor.patch, with .9000 suffixes for development:
0.3.0 released
0.3.0.9000 in development
0.3.1 patch: bug fix, no interface change
0.4.0 minor: new features, backwards compatible
1.0.0 major: stable interface, or a breaking change
usethis::use_version("minor")
usethis::use_dev_version()For study code, tag the version that produced each submission deliverable. “The tables were produced with studyutils 1.2.0, commit a3f9c21” is the statement you need to be able to make.
Documentation with roxygen2
Comments starting #' above a function become its help page.
#' Calculate study day
#'
#' Computes the study day relative to a reference date following the CDISC
#' convention in which there is no day zero: the reference date is day 1 and
#' the day before it is day -1.
#'
#' @param date A `Date` vector of event dates.
#' @param reference A `Date` vector of reference dates, typically the first
#' dose date. Recycled if length 1.
#'
#' @return An integer vector the same length as `date`. `NA` where either
#' input is `NA`.
#'
#' @details
#' The absence of a day zero means the calculation is not simply
#' `date - reference`. For dates on or after the reference, study day is
#' `date - reference + 1`; before it, `date - reference`.
#'
#' @examples
#' study_day(as.Date("2026-03-20"), as.Date("2026-03-15"))
#' study_day(as.Date("2026-03-14"), as.Date("2026-03-15"))
#'
#' @seealso [impute_dtc()] for handling partial dates before calling this.
#' @family date functions
#' @export
study_day <- function(date, reference) {
stopifnot(inherits(date, "Date"), inherits(reference, "Date"))
as.integer(dplyr::if_else(
date >= reference,
as.numeric(date - reference) + 1,
as.numeric(date - reference)
))
}Generate the .Rd files and update NAMESPACE:
devtools::document() # or Ctrl/Cmd + Shift + DKey tags:
| Tag | Purpose |
|---|---|
@param |
One per argument, all required |
@return |
What comes back, including type |
@examples |
Runnable code — checked by R CMD check |
@export |
Make it public (adds to NAMESPACE) |
@importFrom pkg fn |
Import a specific function |
@family name |
Cross-link a group of functions |
@seealso |
Manual cross-references |
@inheritParams fn |
Reuse another function’s @param docs |
@keywords internal |
Document but hide from the index |
@noRd |
Comment only, generate no help page |
@examples are tested
R CMD check runs every example. That makes them the most reliably correct documentation you can write — and it means an example that needs a data file or a database connection must be wrapped:
#' @examples
#' \dontrun{
#' dm <- read_sas("dm.sas7bdat")
#' }
#'
#' @examplesIf interactive()
#' launch_review_app()NAMESPACE
Never edit it by hand — roxygen2 generates it. It controls two things:
Exports — what users see:
#' @export
public_function <- function() {}
internal_helper <- function() {} # no @export: usable inside the package onlyImports — what you use from other packages:
#' @importFrom dplyr filter mutate
#' @importFrom rlang .dataTwo workable styles:
# Style 1: explicit :: everywhere (recommended)
my_fn <- function(data) {
dplyr::filter(data, .data$AGE > 65)
}
# Style 2: importFrom, then bare names
#' @importFrom dplyr filter
my_fn <- function(data) {
filter(data, .data$AGE > 65)
}Style 1 makes the origin of every call obvious to a reader and avoids conflicts between packages that export the same name (filter exists in both dplyr and stats). Use it unless the code becomes unreadable.
R CMD check and NSE
> checking R code for possible problems ... NOTE
my_fn: no visible binding for global variable 'AGE'
This happens because AGE is a column name, not an object. Fix it with the .data pronoun:
#' @importFrom rlang .data
my_fn <- function(data) {
dplyr::filter(data, .data$AGE > 65)
}Or, as a blunt instrument, declare them:
utils::globalVariables(c("AGE", "SEX", "USUBJID"))The .data form is better — it is precise and it documents that the symbol is a column.
Including data
usethis::use_data_raw("adsl") # creates data-raw/adsl.Rdata-raw/adsl.R contains the code that creates the dataset — this is the reproducible part and it is committed:
# data-raw/adsl.R
library(dplyr)
set.seed(42)
adsl <- tibble::tibble(
USUBJID = sprintf("STUDY-001-%04d", 1:100),
TRT01P = sample(c("Placebo", "Drug A", "Drug B"), 100, replace = TRUE),
AGE = round(rnorm(100, 65, 10)),
SEX = sample(c("M", "F"), 100, replace = TRUE),
SAFFL = "Y"
)
usethis::use_data(adsl, overwrite = TRUE)Document it in R/data.R:
#' Example ADSL dataset
#'
#' A synthetic subject-level analysis dataset for examples and tests. Values
#' are randomly generated and do not represent any real study.
#'
#' @format A tibble with 100 rows and 5 variables:
#' \describe{
#' \item{USUBJID}{Unique subject identifier}
#' \item{TRT01P}{Planned treatment for period 1}
#' \item{AGE}{Age in years at screening}
#' \item{SEX}{Sex, `"M"` or `"F"`}
#' \item{SAFFL}{Safety population flag, `"Y"` or `"N"`}
#' }
#' @source Simulated; see `data-raw/adsl.R`.
"adsl"Data in data/ is exported and lazy-loaded. Data in inst/extdata/ is raw files, accessed with:
system.file("extdata", "dm.sas7bdat", package = "studyutils")Never put real patient data in a package.
Vignettes
usethis::use_vignette("deriving-adsl")A vignette is a long-form guide: the narrative that help pages cannot carry. Write one that walks through the intended workflow end to end.
---
title: "Deriving ADSL"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Deriving ADSL}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---pkgdown::build_site() turns help pages, vignettes and README into a website — which is what admiral and the rest of the pharmaverse publish.
R CMD check
devtools::check()It runs roughly 50 checks: the package installs, documentation matches the code, examples run, tests pass, no syntax errors, dependencies are declared, files are where they should be.
-- R CMD check results ------------------------ studyutils 0.3.0 ----
Duration: 42.1s
> checking dependencies in R code ... WARNING
'::' or ':::' import not declared from: 'lubridate'
> checking Rd \usage sections ... NOTE
Undocumented arguments in documentation object 'study_day'
'reference'
0 errors v | 1 warning x | 1 note x
Interpretation:
- ERROR — must fix, the package is broken
- WARNING — must fix for CRAN, should fix regardless
- NOTE — usually fix; some are unavoidable (e.g. first submission)
For internal packages, aim for zero errors and zero warnings. A package that passes check installs cleanly on a colleague’s machine, which is the whole point.
devtools::check(cran = FALSE) # skip CRAN-only checks
devtools::check_win_devel() # Windows, R-devel
rhub::rhub_check() # many platformsThe development cycle
devtools::load_all() # Ctrl/Cmd + Shift + L — load without installing
devtools::document() # Ctrl/Cmd + Shift + D — regenerate docs and NAMESPACE
devtools::test() # Ctrl/Cmd + Shift + T
devtools::check() # Ctrl/Cmd + Shift + E
devtools::install() # install into your libraryload_all() is the one you use hundreds of times a day: it simulates loading the package, including unexported functions, without the install round-trip.
Package or not?
| Use a package | Use scripts |
|---|---|
| Code shared across programs or studies | A one-off analysis |
| Functions that need tests and docs | Exploratory work |
| Anything another person will run | A single, linear pipeline |
| Standards or utility code | Study-specific glue with no reuse |
A common and effective structure: study-specific scripts that call study-agnostic package functions. The scripts are the record of what was run; the package is the tested, documented logic.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Editing NAMESPACE by hand |
Overwritten by document() |
Use @export |
library() inside a package function |
Modifies the user’s search path | Imports + :: |
Everything in Depends |
Attaches packages the user did not ask for | Use Imports |
Forgetting document() |
Docs and code diverge | Run it before every commit |
| Bare column names in NSE | R CMD check NOTEs |
.data$col |
| Real data in the package | Regulatory and privacy problem | Synthetic data only |
| Ignoring check warnings | Fails on someone else’s machine | Zero warnings |
Exercise 11.1 — Package a function
Create a package studyutils containing study_day(), fully documented, with tests, passing R CMD check with zero errors and warnings. List the commands in order.
Show solution
# 1. Skeleton
usethis::create_package("~/dev/studyutils")
# 2. Infrastructure (run inside the new project)
usethis::use_git()
usethis::use_mit_license()
usethis::use_testthat()
usethis::use_readme_md()
usethis::use_package("dplyr")
usethis::use_package("rlang")
# 3. Create R/study-day.R with the roxygen-documented function
usethis::use_r("study-day")
# 4. Generate docs and NAMESPACE
devtools::document()
# 5. Create and write the test file
usethis::use_test("study-day")
devtools::test()
# 6. Full check
devtools::check()
# 7. Commit
usethis::use_git_ignore(c(".Rproj.user", ".Rhistory"))
gert::git_add(".")
gert::git_commit("Add study_day() with tests and documentation")R/study-day.R:
#' Calculate study day
#'
#' @param date A `Date` vector of event dates.
#' @param reference A `Date` vector of reference dates (typically first dose).
#' @return An integer vector; day 1 is the reference date, with no day zero.
#' @examples
#' study_day(as.Date("2026-03-20"), as.Date("2026-03-15"))
#' @export
study_day <- function(date, reference) {
if (!inherits(date, "Date")) stop("`date` must be a Date vector")
if (!inherits(reference, "Date")) stop("`reference` must be a Date vector")
as.integer(dplyr::if_else(
date >= reference,
as.numeric(date - reference) + 1,
as.numeric(date - reference)
))
}tests/testthat/test-study-day.R:
test_that("the reference date is day 1", {
expect_equal(study_day(as.Date("2026-03-15"), as.Date("2026-03-15")), 1L)
})
test_that("there is no day zero", {
expect_equal(study_day(as.Date("2026-03-14"), as.Date("2026-03-15")), -1L)
})
test_that("the result is an integer vector", {
out <- study_day(as.Date(c("2026-03-20", "2026-03-10")), as.Date("2026-03-15"))
expect_type(out, "integer")
expect_length(out, 2)
})
test_that("NA propagates", {
expect_true(is.na(study_day(as.Date(NA), as.Date("2026-03-15"))))
})
test_that("non-Date input is rejected", {
expect_error(study_day("2026-03-20", as.Date("2026-03-15")), "must be a Date")
})Exercise 11.2 — Fix a failing check
devtools::check() reports:
> checking dependencies in R code ... WARNING
'::' or ':::' import not declared from: 'lubridate'
> checking R code for possible problems ... NOTE
derive_ages: no visible binding for global variable 'BRTHDT'
> checking Rd files ... WARNING
Undocumented arguments in documentation object 'derive_ages'
'reference_date'
Explain each and give the fix.
Show solution
1. Undeclared dependency. The code calls lubridate:: but lubridate is not in DESCRIPTION. Install-time resolution would fail on a clean machine.
usethis::use_package("lubridate")2. No visible binding. BRTHDT is a data-masked column name, and R’s code checker sees an undefined symbol. Fix with the .data pronoun:
#' @importFrom rlang .data
derive_ages <- function(data, reference_date) {
dplyr::mutate(data, AGE = compute_age(.data$BRTHDT, reference_date))
}Then devtools::document() to add importFrom(rlang,.data) to NAMESPACE.
3. Undocumented argument. The function gained a reference_date argument but the roxygen block was not updated.
#' @param reference_date A `Date` used as the age reference, typically the
#' informed consent date.devtools::document() and re-check. All three failures share a cause: the code changed and the metadata did not. devtools::check() before every commit — or a CI workflow that runs it on every push — makes the gap impossible to sustain. See Git and GitHub.
Recap
- A package is R’s unit of tested, documented, versioned, shareable code
Importsfor what you use,Suggestsfor tests and vignettes,Dependsfor the R versionroxygen2generates both help pages andNAMESPACE— never editNAMESPACE- Use
pkg::fn()explicitly; use.data$colto keepR CMD checkquiet data-raw/holds the code that creates package data; never ship real patient data- Zero errors and zero warnings from
devtools::check(), always load_all()for the inner loop,check()before every commit
Next: Git and GitHub.