Package development

Lesson 11 — R Programming

Lesson 11 of 12 Intermediate to advanced ~100 min

Learning objectives

  • Create a package skeleton and understand every file in it
  • Document functions with roxygen2 and generate help pages
  • Manage dependencies through DESCRIPTION and NAMESPACE
  • Include data and vignettes
  • Run R CMD check and 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 + D

Key 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
Tip@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 only

Imports — what you use from other packages:

#' @importFrom dplyr filter mutate
#' @importFrom rlang .data

Two 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.

WarningR 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.R

data-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 platforms

The 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 library

load_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.
Then 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
  • Imports for what you use, Suggests for tests and vignettes, Depends for the R version
  • roxygen2 generates both help pages and NAMESPACE — never edit NAMESPACE
  • Use pkg::fn() explicitly; use .data$col to keep R CMD check quiet
  • 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.

Back to top