Introduction
Clinical-trial reporting rarely consists of one isolated table or one isolated figure. A typical study can contain dozens or hundreds of Tables, Listings, and Figures (TLFs), many of which share the same programming patterns.
A demographic table may need treatment-arm columns, counts and percentages, summary statistics, total columns, and a consistent title and footnote. An adverse-event table may use the same treatment-column structure but different statistics and denominators. A laboratory listing may require subject identifiers, visit variables, units, reference ranges, and treatment information.
If each output is programmed independently, small changes become expensive. A formatting correction may need to be repeated across twenty programs. Worse, independently written programs can implement subtly different logic.
Reusable R functions address this problem by turning repeated programming patterns into explicit, testable interfaces.
What Is a Reusable Function?
An R function is a named block of code that accepts inputs, performs an operation, and returns an output.
make_summary <- function(data, variable) {
# perform analysis
result
}
The function can then be called repeatedly:
result1 <- make_summary( data = adsl, variable = "AGE" ) result2 <- make_summary( data = adsl, variable = "WEIGHT" )
The function contains the shared algorithm while the arguments identify what changes from one use to another.
Why Reusability Matters for TLFs
A clinical reporting program often contains three conceptually different layers:
Reusable functions allow these layers to be separated instead of repeatedly interwoven in every TLF program.
For example, the function that calculates a treatment-group summary should not need to know whether the final output will be rendered as HTML or RTF. Similarly, an RTF renderer should not contain clinical rules for defining the safety population.
The TLF Function Design Pattern
Do Not Start by Making Everything Generic
One of the most common mistakes is trying to create an extremely generic function before understanding the repeated pattern.
A function such as:
make_any_tlf( data = ..., analysis = ..., layout = ..., statistics = ..., grouping = ..., footnotes = ..., titles = ..., rendering = ..., ... )
may look flexible, but it can become difficult to understand, validate, and debug. The function effectively becomes a second programming language.
A better strategy is to identify a repeated family of TLFs and abstract only the decisions that genuinely vary.
What Belongs in a Function Argument?
A good function argument represents a decision that legitimately changes between calls.
| Candidate | Usually an argument? | Reason |
|---|---|---|
| Input dataset | Yes | The same function may operate on multiple analysis datasets. |
| Population filter | Yes | Different TLFs may use different analysis populations. |
| Analysis variable | Yes | The summary may be applied to several variables. |
| Treatment variable | Often | Study conventions may differ across programs or domains. |
| Output path | Sometimes | Useful for rendering functions, less useful for pure analysis functions. |
| Fixed clinical rule | Usually no | Rules specified by the SAP should not be casually overridden. |
| Internal temporary variable | No | It is an implementation detail. |
Explicit Arguments Are Better Than Hidden State
Consider a function that silently reads a global object:
make_table <- function() {
data <- adsl
...
}
This creates hidden dependencies. The function cannot be understood from its signature, and its behavior can change depending on objects in the global environment.
Prefer:
make_table <- function(data) {
...
}
Now the dependency is explicit.
Design the Function Signature First
Before writing the implementation, define the interface.
make_demographic_table <- function(
data,
treatment_var = "TRT01P",
variables = c("AGE", "SEX", "RACE"),
population = NULL,
include_total = TRUE,
digits = 1
) {
# implementation
}
The signature communicates much of the function's intended behavior before a reviewer reads the body.
It also gives you a natural place to document defaults and required inputs.
Required Versus Optional Arguments
Not every parameter should have a default.
If the function cannot produce a valid result without a dataset, make
data required.
make_tlf <- function(data, ...) {
...
}
If a treatment variable follows a study-wide convention, a default can be appropriate:
make_tlf <- function(
data,
treatment_var = "TRT01P"
) {
...
}
Defaults reduce repetitive code, but they should never hide a clinically important decision.
Named Arguments Improve TLF Auditability
Compare:
make_table(adsl, "TRT01P", c("AGE", "SEX"), TRUE, 1)
with:
make_table(
data = adsl,
treatment_var = "TRT01P",
variables = c("AGE", "SEX"),
include_total = TRUE,
digits = 1
)
The second call is longer but substantially easier to review and audit.
Validate Inputs Early
A reusable function should fail near the source of the problem.
Instead of allowing a missing column to produce a confusing error halfway through a pipeline, validate the required inputs first.
required_cols <- c(
"USUBJID",
"TRT01P",
"AGE"
)
missing_cols <- setdiff(
required_cols,
names(data)
)
if (length(missing_cols) > 0) {
stop(
"Missing required columns: ",
paste(missing_cols, collapse = ", ")
)
}
This is especially important for reusable TLF functions because the same function may be called by many programs.
Use Small Validation Helpers
Repeated validation can itself become repetitive. A small helper function can centralize it.
check_required_columns <- function(data, required) {
missing <- setdiff(required, names(data))
if (length(missing) > 0) {
stop(
"Missing required columns: ",
paste(missing, collapse = ", ")
)
}
invisible(TRUE)
}
Then:
check_required_columns(
data,
c("USUBJID", "TRT01P", "AGE")
)
This gives every TLF function a consistent validation behavior.
Validate Argument Values Too
Column existence is only one kind of validation.
Suppose a function accepts a statistic:
statistic = "mean"
The function should reject unsupported values.
allowed_stats <- c(
"mean",
"median",
"sd",
"min",
"max"
)
if (!statistic %in% allowed_stats) {
stop(
"statistic must be one of: ",
paste(allowed_stats, collapse = ", ")
)
}
Failing early is generally safer than silently falling back to an unexpected behavior.
Separate Data Preparation From Formatting
One of the most important design decisions in TLF programming is separating the calculation of the result from its visual rendering.
For example:
summary_data <- summarize_demographics(
data = adsl,
variables = c("AGE", "SEX", "RACE")
)
tlf_table <- format_demographic_table(
summary_data = summary_data
)
render_tlf(
tlf_table,
file = "Table_14_1_1.rtf"
)
This architecture allows the analysis object to be inspected independently of the final document.
Analysis function
Answers: "What values should appear?" It should be as independent as possible from rendering.
Formatting function
Answers: "How should those values be arranged and displayed?"
Rendering function
Answers: "How should the controlled object become the final artifact?"
Driver program
Answers: "Which analysis, parameters, and output are required for this TLF?"
A Simple Reusable Continuous Summary
Consider a function that creates a mean, standard deviation, minimum, median, and maximum for a continuous variable.
summarize_continuous <- function(
data,
variable
) {
x <- data[[variable]]
data.frame(
N = sum(!is.na(x)),
Mean = mean(x, na.rm = TRUE),
SD = sd(x, na.rm = TRUE),
Median = median(x, na.rm = TRUE),
Min = min(x, na.rm = TRUE),
Max = max(x, na.rm = TRUE)
)
}
The function can then be reused:
summarize_continuous(adsl, "AGE") summarize_continuous(adsl, "WEIGHT") summarize_continuous(adsl, "HEIGHT")
This is a small example, but the design principle scales to much more complex clinical reporting.
Handling All-Missing Variables
The simple function above has an important edge case: what happens if every value is missing?
A production function should define this behavior explicitly.
summarize_continuous <- function(
data,
variable
) {
x <- data[[variable]]
n <- sum(!is.na(x))
if (n == 0) {
return(data.frame(
N = 0,
Mean = NA_real_,
SD = NA_real_,
Median = NA_real_,
Min = NA_real_,
Max = NA_real_
))
}
data.frame(
N = n,
Mean = mean(x, na.rm = TRUE),
SD = sd(x, na.rm = TRUE),
Median = median(x, na.rm = TRUE),
Min = min(x, na.rm = TRUE),
Max = max(x, na.rm = TRUE)
)
}
Adding Treatment Groups
A clinical TLF usually requires treatment-specific denominators and summaries.
summarize_by_treatment <- function(
data,
variable,
treatment_var = "TRT01P"
) {
split_data <- split(
data,
data[[treatment_var]]
)
lapply(
split_data,
function(d) summarize_continuous(
data = d,
variable = variable
)
)
}
This demonstrates a useful abstraction: the function knows how to perform the same summary for each treatment group, while the calling program determines which variable and treatment variable are used.
Why Denominators Deserve Explicit Treatment
In clinical reporting, a percentage is not meaningful without knowing its denominator.
For example:
A reusable function should therefore avoid guessing the denominator.
If a table is based on the safety population, the function should receive data already restricted to that population or receive an explicit population definition that can be traced to the analysis plan.
Do Not Hide Population Definitions in Formatting Code
Avoid a design such as:
make_ae_table <- function(data) {
data <- data |>
dplyr::filter(SAFFL == "Y")
...
}
This can be acceptable in a narrowly defined study utility, but it becomes problematic when the same function is reused for different populations.
A more explicit architecture is:
make_ae_table <- function(
data,
population_filter
) {
data <- population_filter(data)
...
}
Or, in many production environments, perform the population selection in the driver and pass the analysis-ready dataset into the function.
Parameterizing a Demographic Table
A useful demographic-table function might accept:
make_demographic_table <- function(
data,
treatment_var = "TRT01P",
continuous_vars = character(),
categorical_vars = character(),
include_total = TRUE,
digits = 1,
title = NULL,
footnote = NULL
) {
...
}
The function now has a clear contract. It receives data, identifies the treatment grouping, identifies variables, controls whether a total column is included, and receives presentation metadata.
Function Arguments Should Have One Meaning
Avoid arguments that change multiple unrelated behaviors.
For example:
style = "full"
is ambiguous. Does "full" mean full borders, all statistics, a total column, bold headings, or all of them?
Prefer explicit parameters:
include_total = TRUE show_sd = TRUE show_range = FALSE border_style = "minimal"
Explicit interfaces are easier to validate and review.
Use Configuration Objects When Parameters Multiply
As a function grows, passing twenty individual formatting arguments can become unwieldy.
At that point, a configuration object can help:
tlf_cfg <- list( digits = 1, include_total = TRUE, show_sd = TRUE, font_size = 9, table_width = "100%" ) make_demographic_table( data = adsl, config = tlf_cfg )
The configuration can be version-controlled and reused across a family of TLFs.
Do Not Put Everything Into One Giant Function
A function that calculates statistics, derives analysis flags, creates footnotes, formats cells, writes RTF, creates PDFs, and logs validation messages is difficult to test.
Prefer a pipeline of focused functions.
Reusable Functions for Listings
Listings often appear simple because they do not calculate many statistics. However, reusable functions can still provide substantial value.
For example, many safety listings share:
- Subject identifier
- Treatment
- Visit or date
- Parameter or event description
- Severity
- Relationship
- Relevant laboratory or clinical values
A generic listing constructor might accept a column specification:
make_listing <- function(
data,
columns,
labels = NULL,
filter = NULL,
arrange_by = NULL
) {
...
}
The key is that the function should control structure without inventing clinical meaning.
Column Specifications
A metadata-driven approach can be especially useful for listings.
listing_spec <- data.frame(
variable = c(
"USUBJID",
"TRT01P",
"AESTDTC",
"AEDECOD",
"AESEV"
),
label = c(
"Subject",
"Treatment",
"Start Date",
"Preferred Term",
"Severity"
),
stringsAsFactors = FALSE
)
The listing function can use this specification to select, rename, and order columns.
Reusable Functions for Figures
The same principles apply to figures.
Suppose several oncology figures need the same treatment legend, theme, axis formatting, and export behavior.
make_spider_plot <- function(
data,
x,
y,
subject,
treatment = NULL,
y_limits = NULL,
reference_lines = NULL,
title = NULL
) {
# ggplot construction
}
The function can encapsulate common presentation conventions while leaving the scientific variables explicit.
Keep Scientific Decisions Separate From Cosmetic Decisions
Consider:
make_spider_plot( data = adtr, x = "ADY", y = "PCHG", subject = "USUBJID", y_limits = c(-100, 100), title = "Percent Change from Baseline" )
Here, the function handles the graphical implementation.
The decision that PCHG represents a particular
clinical endpoint should remain traceable to the analysis dataset and
specification.
Use Tidy Evaluation Carefully
Reusable R functions often need to accept column names.
One approach is to accept strings:
summarize_variable <- function(data, variable) {
x <- data[[variable]]
mean(x, na.rm = TRUE)
}
This is straightforward and often preferable in metadata-driven clinical programming because variable names commonly come from specifications.
With the tidyverse, another approach is to accept unquoted names using tidy evaluation:
library(dplyr)
summarize_variable <- function(data, variable) {
data |>
summarise(
mean_value = mean({{ variable }}, na.rm = TRUE)
)
}
Both approaches are valid. The important point is to choose deliberately and use the same convention consistently within a function library.
Strings Are Often Useful in Metadata-Driven TLF Systems
Clinical reporting often relies on metadata tables that store variable names, labels, statistics, and display orders.
For example:
spec <- list(
variable = "AGE",
label = "Age (years)",
statistics = c("n", "mean", "sd", "median", "range")
)
A function can consume this metadata directly.
This approach can make a reporting system easier to scale because the driver program contains study-specific specifications while the reusable function contains implementation logic.
Formatting Numbers Inside the Reporting Layer
A common source of inconsistency is numeric formatting.
The analysis layer should generally retain numeric values as numeric values. Formatting such as:
"23.4" "23.40" "23" "23.4%
belongs in a display layer rather than being introduced prematurely into the analysis dataset.
A small helper can standardize numeric display:
format_num <- function(x, digits = 1) {
ifelse(
is.na(x),
"",
formatC(
x,
format = "f",
digits = digits
)
)
}
This distinction is particularly important because numeric values may be used again for validation, calculations, or alternate outputs.
Counts and Percentages
A reusable categorical summary often needs both counts and percentages.
format_n_pct <- function(
n,
denominator,
digits = 1
) {
if (is.na(n) || is.na(denominator) || denominator == 0) {
return("")
}
pct <- 100 * n / denominator
paste0(
n,
" (",
format_num(pct, digits),
"%)"
)
}
Again, the function makes the display rule explicit and testable.
Be Explicit About Denominator Rules
Suppose an adverse-event table displays:
That denominator should not be silently inferred from whichever rows happen to be present after filtering.
In reusable clinical code, denominator logic should be a documented part of the function contract or supplied as a controlled input.
Returning Structured Objects Is Often Better Than Returning Only a File
A rendering function might write an RTF file, but a lower-level analysis function should usually return data.
summary <- summarize_demographics(
data = adsl,
variables = c("AGE", "SEX")
)
summary
This makes the intermediate result inspectable.
It also supports automated validation:
stopifnot(
nrow(summary) > 0,
all(c("label", "statistic") %in% names(summary))
)
Use Predictable Return Values
A function should have a clear expectation about what it returns.
| Function type | Good return value |
|---|---|
| Derivation | Analysis-ready data frame |
| Summary | Structured summary data frame or list |
| Formatting | Formatted table object |
| Validation | Logical result, diagnostic object, or informative error |
| Rendering | File path or controlled render result |
Avoid functions whose return type changes unpredictably depending on the input.
Use Helper Functions to Reduce Duplication
Suppose every table needs a title block.
Instead of rebuilding it in every TLF function:
make_title_block <- function(
number,
title,
population = NULL
) {
list(
number = number,
title = title,
population = population
)
}
The same helper can be reused by demographic tables, safety tables, and efficacy tables.
But Avoid Premature Abstraction
Not every two lines of duplicated code need to become a function.
Abstraction has a cost: reviewers must understand another layer before they can understand the TLF.
A useful rule is:
- Abstract repeated logic that has a stable meaning.
- Keep one-off clinical decisions visible in the driver program.
- Do not hide study-specific rules merely to reduce line count.
Use Assertions for TLF Invariants
An invariant is a condition that should always be true.
Examples include:
- A subject should not belong to two mutually exclusive treatment groups.
- A percentage should not be calculated with a zero denominator.
- A table row order should match its specification.
- A required statistic should exist for every requested treatment group.
Assertions can detect unexpected conditions:
if (any(summary$n < 0, na.rm = TRUE)) {
stop("Invalid negative count detected.")
}
Validation of Treatment Groups
Treatment-group order is another important invariant.
expected_trt <- c(
"Placebo",
"Drug A Low Dose",
"Drug A High Dose"
)
observed_trt <- unique(data$TRT01P)
unexpected <- setdiff(
observed_trt,
expected_trt
)
if (length(unexpected) > 0) {
stop(
"Unexpected treatment group(s): ",
paste(unexpected, collapse = ", ")
)
}
This can prevent a new or malformed treatment category from silently appearing in a production table.
Factor Levels and Display Order
Never rely on alphabetical ordering when clinical reporting requires a specific order.
data$TRT01P <- factor(
data$TRT01P,
levels = c(
"Placebo",
"Drug A Low Dose",
"Drug A High Dose"
)
)
The same principle applies to severity, response category, visit, and other ordered variables.
Designing a Reusable TLF Driver
A driver program should read like a specification.
library(myTlfTools)
adsl <- readRDS("adam/adsl.rds")
tbl <- make_demographic_table(
data = adsl,
treatment_var = "TRT01P",
continuous_vars = c("AGE", "WEIGHT"),
categorical_vars = c("SEX", "RACE"),
include_total = TRUE,
digits = 1
)
render_tlf(
tbl,
output = "outputs/Table_14_1_1.rtf"
)
The implementation details are in the reusable package or function library; the driver communicates what this particular TLF requires.
Parameter Files Can Reduce Driver Complexity
If many TLFs share a common architecture, parameters can be stored separately.
table_14_1_1 <- list(
id = "14.1.1",
title = "Demographic Characteristics",
dataset = "ADSL",
treatment_var = "TRT01P",
continuous_vars = c("AGE", "WEIGHT"),
categorical_vars = c("SEX", "RACE"),
include_total = TRUE
)
The driver then passes the configuration to the reusable function.
make_demographic_table( data = adsl, config = table_14_1_1 )
This approach can be particularly effective when a study contains many similarly structured outputs.
When Metadata-Driven Programming Helps
Metadata-driven TLF generation works well when the structure is repetitive and the variations can be represented declaratively.
| Good metadata candidate | Why |
|---|---|
| Variable name | Controls which analysis variable is summarized. |
| Label | Controls display text. |
| Order | Controls row or column placement. |
| Statistic list | Controls requested summaries. |
| Format | Controls numeric display. |
| Population flag | Can identify a prespecified analysis population. |
Metadata is less appropriate for complicated clinical algorithms that become harder to understand when represented as opaque configuration values.
Document the Function Contract
Every reusable function should document:
- Purpose
- Required inputs
- Optional inputs and defaults
- Expected variable types
- Population assumptions
- Return value
- Important edge cases
- Examples
For package development, roxygen2 provides a convenient documentation format:
#' Create a continuous clinical summary
#'
#' @param data Analysis dataset.
#' @param variable Character scalar containing the analysis variable.
#' @return A one-row data frame containing N, mean, SD, median, minimum,
#' and maximum.
#' @export
summarize_continuous <- function(data, variable) {
...
}
Type Checking
A function that expects a character variable name should validate that input.
if (
!is.character(variable) ||
length(variable) != 1L ||
is.na(variable)
) {
stop("variable must be one non-missing character value.")
}
Likewise, a numeric argument can be checked:
if (
!is.numeric(digits) ||
length(digits) != 1L ||
digits < 0
) {
stop("digits must be a non-negative numeric scalar.")
}
Use Informative Error Messages
Compare:
Error: object 'x' not found
with:
Error: make_demographic_table(): required variable 'AGE' was not found in the supplied analysis dataset.
The second error immediately identifies where the failure occurred and what needs to be corrected.
Warnings Versus Errors
Not every unexpected condition should terminate the program.
Use an error when continuing would make the output invalid.
stop("Required treatment variable is missing.")
Use a warning when the output can still be valid but deserves attention.
warning( "No post-baseline observations were found for the requested subset." )
The distinction should be deliberate and documented.
Testing a Reusable Function
Reusable functions should be tested independently before being used across many TLFs.
At minimum, test:
- Normal input
- Empty input
- Missing required columns
- All-missing analysis values
- Unexpected treatment groups
- Zero denominators
- Single-subject data
- Multiple treatment groups
- Boundary values
Unit Testing With testthat
The testthat package is commonly used for R unit
testing.
library(testthat)
test_that(
"continuous summary calculates mean correctly",
{
dat <- data.frame(
AGE = c(20, 30, 40)
)
result <- summarize_continuous(
data = dat,
variable = "AGE"
)
expect_equal(
result$Mean,
30
)
}
)
A reusable TLF library should have tests that run independently of the individual TLF driver programs.
Test the Edge Cases
test_that(
"all missing values return zero N",
{
dat <- data.frame(
AGE = c(NA_real_, NA_real_)
)
result <- summarize_continuous(
data = dat,
variable = "AGE"
)
expect_equal(result$N, 0)
expect_true(is.na(result$Mean))
}
)
Edge-case tests are particularly valuable because reusable functions are expected to encounter a wider range of inputs than one-off scripts.
Golden or Snapshot Testing for TLFs
For rendered tables and figures, numerical unit tests may not be sufficient. A TLF can contain the correct numbers but still have an incorrect title, row order, footnote, or layout.
One approach is to compare a structured representation or snapshot of the expected output.
The principle is:
Independent Validation
A reusable function can reduce programming duplication, but it does not remove the need for independent validation.
A validation program can independently calculate selected key values:
expected_n <- sum( !is.na(adsl$AGE) ) actual_n <- tbl_summary$n[ tbl_summary$variable == "AGE" ] stopifnot( expected_n == actual_n )
For important TLFs, compare selected values against an independent method rather than simply calling the same helper function twice.
Do Not Validate With the Same Logic You Are Testing
This is a subtle but important issue.
If the production function contains:
mean(x, na.rm = TRUE)
and the validation program calculates:
mean(x, na.rm = TRUE)
both can contain the same defect.
Independent validation is stronger when the implementation path differs. For example, compare against a manually calculated value, a controlled reference dataset, or an independently programmed algorithm.
Reproducibility and Package Versions
Reusable functions depend on their R packages and versions.
A production TLF environment should therefore control dependencies.
For example, renv can record package versions for
an R project.
renv::init() renv::snapshot() renv::restore()
The exact package-management strategy depends on the organization's validated computing environment, but dependency control is an important part of reproducible reporting.
Keep Function Libraries Under Version Control
Reusable TLF functions are production code and should be version-controlled.
A typical repository might contain:
R/
summarize_continuous.R
summarize_categorical.R
make_demographic_table.R
make_ae_table.R
make_listing.R
make_spider_plot.R
format_numbers.R
validate_inputs.R
tests/
testthat/
test-summary-continuous.R
test-summary-categorical.R
test-demographic-table.R
test-validation.R
inst/
templates/
vignettes/
tlf-function-guide.Rmd
The precise structure can vary, but separating reusable code from individual study drivers makes maintenance substantially easier.
Function Libraries Versus Study-Specific Programs
| Reusable library | Study-specific driver |
|---|---|
| Generic summary algorithms | Which population is used |
| Formatting utilities | Specific table number and title |
| Validation helpers | Specific dataset |
| Common rendering conventions | Specific variables |
| Shared plot construction | Study-specific endpoint |
This separation is one of the most useful architectural patterns for clinical R reporting.
Versioning Functions Carefully
A change to a shared function can affect many TLFs.
Suppose a function changes:
format_num(x, digits = 1)
to:
format_num(x, digits = 2)
A seemingly harmless change can alter dozens of outputs.
Therefore, shared-function changes should be treated as controlled changes, with regression testing and impact assessment.
Semantic Versioning Can Help
For reusable packages, semantic versioning can communicate the nature of a change:
- Patch: bug fix that should preserve the interface.
- Minor: backward-compatible functionality.
- Major: breaking interface or behavioral change.
Organizations may use different controlled software lifecycle conventions, so the package-versioning approach should align with the applicable SOPs and validation framework.
Logging and Traceability
Production TLF generation benefits from informative logs.
message( "Generating Table 14.1.1 using dataset ADAM.ADSL" ) message( "Population: treated subjects" ) message( "Treatment variable: TRT01P" )
A structured log can make it easier to reconstruct what happened during a batch run.
Do Not Let Logging Change Results
Logging should describe execution rather than introduce hidden analysis behavior.
Avoid a function that changes its calculations depending on whether verbose logging is enabled.
verbose = TRUE
should affect diagnostics, not clinical results.
Reusable Functions for Batch TLF Generation
Once functions are stable, a study can generate multiple outputs from a controlled specification.
tlf_specs <- list(
table_14_1_1 = list(
type = "demographic",
dataset = "ADSL"
),
table_14_2_1 = list(
type = "adverse_event",
dataset = "ADAE"
),
figure_14_3_1 = list(
type = "spider",
dataset = "ADTR"
)
)
A dispatcher can select the appropriate function:
generate_tlf <- function(
spec,
data
) {
switch(
spec$type,
demographic =
make_demographic_table(
data = data
),
adverse_event =
make_ae_table(
data = data
),
spider =
make_spider_plot(
data = data
),
stop("Unsupported TLF type.")
)
}
This can scale well, but dispatch systems should remain transparent. A dispatcher that contains dozens of hidden exceptions can become difficult to maintain.
One Function Per TLF or One Function Per Pattern?
There is no universal answer.
A useful hierarchy is:
This gives you reuse without forcing every clinical output through one giant function.
Reusable Functions and Traceability
Traceability is not just a documentation exercise. Function design can make traceability easier.
For example, a driver can explicitly state:
analysis_data <- adsl |>
dplyr::filter(SAFFL == "Y")
tbl <- make_demographic_table(
data = analysis_data,
treatment_var = "TRT01P",
continuous_vars = c("AGE", "WEIGHT")
)
A reviewer can see the population selection before the reusable function is called.
This is often preferable to hiding every study-specific decision inside the library.
Functions Should Not Become Black Boxes
A reusable function is successful when it reduces repetitive implementation without preventing review.
Warning signs include:
- Dozens of arguments with unclear interactions
- Nonstandard evaluation that is difficult to trace
- Hidden global variables
- Implicit population filters
- Silent defaults for clinical decisions
- Large functions containing unrelated responsibilities
- Outputs that change type unexpectedly
A Production-Quality Function Skeleton
#' Create a reusable demographic TLF
#'
#' @param data Analysis-ready subject-level dataset.
#' @param treatment_var Treatment-group variable.
#' @param continuous_vars Continuous variables to summarize.
#' @param categorical_vars Categorical variables to summarize.
#' @param include_total Logical; include total column.
#' @param digits Number of decimal places for continuous summaries.
#' @return Structured table object.
#' @export
make_demographic_table <- function(
data,
treatment_var = "TRT01P",
continuous_vars = character(),
categorical_vars = character(),
include_total = TRUE,
digits = 1
) {
check_required_columns(
data,
treatment_var
)
requested_vars <- c(
continuous_vars,
categorical_vars
)
check_required_columns(
data,
requested_vars
)
if (!is.logical(include_total) ||
length(include_total) != 1L) {
stop("include_total must be a single logical value.")
}
if (!is.numeric(digits) ||
length(digits) != 1L ||
digits < 0) {
stop("digits must be a non-negative numeric scalar.")
}
# Analysis
summary_data <- summarize_demographics(
data = data,
treatment_var = treatment_var,
continuous_vars = continuous_vars,
categorical_vars = categorical_vars,
include_total = include_total
)
# Presentation
formatted_data <- format_demographic_table(
summary_data = summary_data,
digits = digits
)
formatted_data
}
The function is still substantial, but its responsibilities are clear because the lower-level analysis and formatting operations have been separated.
A Reusable Function Should Have a Stable Contract
Think of the function signature as an API.
Changing the function's internal implementation should ideally not require changing every caller.
This is one reason to keep interfaces small and meaningful.
Do Not Expose Implementation Details as Arguments
Suppose a function internally uses a particular join method.
There is usually no reason for every caller to pass:
join_method = "left"
unless that is genuinely a meaningful reporting decision.
Expose business or analytical decisions, not arbitrary implementation details.
Handling Optional Features
Optional features can be useful when they are clearly defined.
make_spider_plot( data = adtr, show_points = TRUE, show_reference_lines = TRUE, show_patient_labels = FALSE )
Each option has a single understandable purpose.
Avoid compound flags such as:
advanced = TRUE
when nobody can tell what "advanced" changes.
Reusable Figure Functions and Axis Rules
Figure functions should make important axis decisions explicit.
make_spider_plot <- function(
data,
x,
y,
subject,
y_limits = NULL,
y_breaks = NULL,
x_breaks = NULL
) {
...
}
This prevents an internal default from silently truncating clinically important observations.
Do Not Silently Drop Data
Reusable functions often contain filtering steps. Those filters should be visible, documented, and tested.
Avoid:
data <- data |> dplyr::filter(!is.na(PCHG))
if missingness itself matters to the intended analysis and the function does not document this behavior.
If missing values are excluded only for a specific statistic, that rule should be part of the statistical definition.
Reusable Functions for Clinical Events
For adverse-event tables, a reusable function may need parameters for:
- Event variable
- Preferred-term variable
- System-organ-class variable
- Severity variable
- Relationship variable
- Population denominator
- Sorting order
- Threshold for display
The function should not assume that an event is "treatment-emergent" unless that definition is explicitly part of the supplied analysis data or function contract.
Threshold Parameters Need Special Care
Some tables display only events occurring in a specified percentage of subjects.
threshold = 5
This is a legitimate parameter if the reporting specification permits it.
But the function should define:
- Which denominator is used
- Whether the threshold is applied per treatment group or overall
- Whether equality counts
- How rounding is handled
- Whether the filter is applied before or after treatment-specific summaries
A parameter without a precise definition is not a reusable specification.
Rounding Is Part of the Reporting Specification
Suppose a percentage is exactly 4.95%.
Whether it displays as 5.0% or 4.9% can affect threshold-based reporting.
The function should therefore define rounding behavior explicitly rather than letting different formatting functions produce inconsistent results.
Testing Rounding Behavior
test_that(
"percentage formatting follows specification",
{
expect_equal(
format_num(4.95, digits = 1),
"5.0"
)
}
)
The exact expected result should reflect the reporting specification and chosen rounding convention.
Performance: Reuse Can Also Improve Efficiency
Reusable functions can reduce duplicated data processing.
However, abstraction does not automatically make code faster.
Avoid repeatedly reading the same large analysis dataset inside every helper.
Prefer:
adsl <- readRDS("adsl.rds")
table1 <- make_demographic_table(adsl, ...)
table2 <- make_exposure_table(adsl, ...)
table3 <- make_disposition_table(adsl, ...)
rather than having each function independently read the same file.
Vectorization and Repeated Operations
When reusable functions process large clinical datasets, vectorized operations are generally preferable to explicit row-by-row loops when practical.
For example, use:
data |>
dplyr::mutate(
PCHG = 100 * (AVAL - BASE) / BASE
)
rather than manually iterating over rows unless the algorithm genuinely requires sequential logic.
Reusability Across Studies
A function library may be reused across studies, but assumptions must be examined carefully.
For example, a treatment variable called TRT01P
may exist in many studies, but the expected treatment labels and ordering may
differ.
Therefore, reusable code should distinguish between:
- Stable programming conventions
- Study-specific metadata
- Study-specific clinical definitions
Cross-Study Reuse Requires Configuration, Not Guessing
A good reusable function might accept:
treatment_levels = c( "Placebo", "Drug A", "Drug B" )
rather than assuming those levels internally.
Likewise, labels should usually come from controlled metadata rather than being hard-coded into generic functions.
Clinical Standards and Function Design
When working with CDISC-oriented reporting, reusable functions should operate on well-defined analysis datasets and variables whose derivations are already controlled.
The function should not casually redefine an ADaM concept because doing so can break traceability between the analysis dataset, statistical analysis plan, TLF shell, and final output.
TLF Shells and Reusable Functions
A useful workflow is:
Reusable Functions and TLF Shell Changes
One major benefit of a reusable architecture is controlled change.
Suppose a study changes its standard decimal display from one decimal place to two for a class of measurements.
If formatting is centralized, the change may be implemented through one controlled configuration update rather than dozens of independent edits.
But this benefit comes with responsibility: the impact of the shared change must be assessed across all dependent TLFs.
Debugging Reusable TLF Functions
Debugging is easier when functions are small and intermediate objects are inspectable.
Instead of:
generate_entire_study()
prefer the ability to inspect:
analysis_data summary_data formatted_table rendered_output
Each layer should be testable independently.
Use Browser or File-Level Review for Final Outputs
A TLF can pass data-level checks and still contain a visual problem.
For example:
- A column can be too narrow.
- A footnote can wrap unexpectedly.
- A page break can split a table incorrectly.
- A figure legend can overlap the plotting region.
- A title can be truncated.
Therefore, reusable rendering functions should be tested with representative outputs, not only data frames.
A Practical Testing Matrix
| Test layer | Example question |
|---|---|
| Input validation | Are required variables present? |
| Derivation | Are analysis values correct? |
| Summary | Are counts, percentages, and statistics correct? |
| Ordering | Are treatment groups and rows in the correct order? |
| Formatting | Are values displayed according to the specification? |
| Rendering | Does the final file match the intended layout? |
| Regression | Did a shared-function change alter unrelated TLFs? |
Example: A Complete Small Function Family
The following simplified architecture illustrates how the pieces fit together.
check_required_columns <- function(data, required) {
missing <- setdiff(required, names(data))
if (length(missing) > 0) {
stop(
"Missing required columns: ",
paste(missing, collapse = ", ")
)
}
invisible(TRUE)
}
summarize_continuous <- function(
data,
variable
) {
check_required_columns(
data,
variable
)
x <- data[[variable]]
n <- sum(!is.na(x))
if (n == 0) {
return(data.frame(
N = 0,
Mean = NA_real_,
SD = NA_real_,
Median = NA_real_,
Min = NA_real_,
Max = NA_real_
))
}
data.frame(
N = n,
Mean = mean(x, na.rm = TRUE),
SD = sd(x, na.rm = TRUE),
Median = median(x, na.rm = TRUE),
Min = min(x, na.rm = TRUE),
Max = max(x, na.rm = TRUE)
)
}
format_summary <- function(
summary,
digits = 1
) {
summary$Mean <- format_num(
summary$Mean,
digits
)
summary$SD <- format_num(
summary$SD,
digits
)
summary$Median <- format_num(
summary$Median,
digits
)
summary
}
make_continuous_tlf <- function(
data,
variable,
digits = 1
) {
summary <- summarize_continuous(
data,
variable
)
format_summary(
summary,
digits
)
}
Each function has a relatively narrow responsibility, and the final function composes those pieces into a reporting operation.
When a Wrapper Function Is Useful
A wrapper can provide a convenient study-facing interface without duplicating the underlying algorithms.
make_age_table <- function(
adsl,
digits = 1
) {
make_continuous_tlf(
data = adsl,
variable = "AGE",
digits = digits
)
}
This can be useful when a particular TLF is conceptually important and should have a highly readable driver call.
Wrappers Should Not Duplicate Logic
The wrapper:
make_age_table <- function(adsl) {
# 100 lines of independent age-table logic
}
defeats much of the purpose of the reusable library.
A wrapper should usually provide configuration and orchestration rather than reimplementing the algorithm.
Reusable Functions for Multi-Arm Studies
Functions should not assume that a study has exactly two treatment groups.
A robust summary function should work with one, two, three, or more groups, subject to the reporting specification.
treatment_levels <- unique(
data[[treatment_var]]
)
for (trt in treatment_levels) {
# summarize each treatment group
}
The output specification should determine the ordering and whether a total column is required.
Subgroup Tables
Reusable functions can also support subgroup reporting.
make_subgroup_table <- function(
data,
subgroup_var,
treatment_var = "TRT01P",
analysis_var
) {
# split by subgroup
# summarize within treatment
# assemble rows
}
The key is to distinguish the subgroup dimension from the treatment dimension and to define denominators correctly for each.
Nested Grouping
Some TLFs require nested structures such as:
A reusable function can generate this hierarchy if the nesting rules are stable.
But if a particular study has a unique clinical hierarchy, it may be better to keep that decision in the study-specific driver.
Handling Empty Treatment Groups
An important edge case occurs when a treatment group is part of the planned output but has no subjects in the analysis population.
The function should follow the TLF specification rather than dropping the column automatically.
For example, a planned column may need to remain visible with an appropriate empty or zero representation.
Clinical Reporting and Missingness
Missing values should be handled according to the statistical definition, not according to whatever behavior happens to be convenient for the programmer.
A reusable function should make assumptions about missingness explicit.
For example:
na_rm = TRUE
may be appropriate for a descriptive mean, but not necessarily for every analysis.
Do Not Use Reusability to Hide Missing Data
A generic helper such as:
clean_data <- function(data) {
tidyr::drop_na(data)
}
can be dangerous if applied indiscriminately to clinical datasets.
Removing rows because any variable is missing can change denominators and analysis populations.
Reusable utilities should therefore be narrowly scoped and semantically named.
Names Matter
Prefer:
drop_missing_analysis_values()
over:
clean_data()
The first name communicates a specific operation; the second suggests a vague and potentially dangerous transformation.
Use Pure Functions Where Practical
A pure function depends only on its explicit inputs and does not modify external state.
For example:
pct_change <- function(
value,
baseline
) {
100 * (value - baseline) / baseline
}
The result is determined entirely by the inputs.
Pure helpers are especially easy to unit test.
Example: A Pure Percentage-Change Helper
pct_change <- function(
value,
baseline
) {
ifelse(
is.na(value) | is.na(baseline) | baseline == 0,
NA_real_,
100 * (value - baseline) / baseline
)
}
The helper can then be reused in analysis code and tested independently.
Testing Pure Helpers
test_that(
"percentage change is correct",
{
expect_equal(
pct_change(75, 100),
-25
)
expect_equal(
pct_change(125, 100),
25
)
expect_true(
is.na(pct_change(10, 0))
)
}
)
Reusable Functions for Statistical Summaries
A function can parameterize the statistic while still controlling the allowed set.
calc_stat <- function(
x,
statistic
) {
switch(
statistic,
mean = mean(x, na.rm = TRUE),
median = median(x, na.rm = TRUE),
sd = sd(x, na.rm = TRUE),
min = min(x, na.rm = TRUE),
max = max(x, na.rm = TRUE),
stop(
"Unsupported statistic: ",
statistic
)
)
}
This is preferable to evaluating arbitrary text as R code.
Avoid eval(parse()) for Routine TLF Logic
Code such as:
eval(parse(text = expression_text))
can make a reporting system difficult to audit and validate.
If the set of supported operations is known, explicit dispatch such as
switch() is generally easier to understand.
Reusable Functions and Security
Even in internal clinical environments, avoid designs that execute arbitrary strings supplied by configuration files.
Metadata should describe controlled behavior rather than become executable code.
This also improves auditability because the permitted operations are visible in the function implementation.
Handling Dates Consistently
Clinical listings frequently involve dates.
A reusable date-formatting helper can standardize display without altering the underlying date.
format_date_display <- function(
x,
format = "%d%b%Y"
) {
ifelse(
is.na(x),
"",
format(x, format = format)
)
}
Again, retain the original date object for analysis and create a formatted character representation only for presentation.
Reusable Functions for Lab Tables
Laboratory TLFs often require additional logic:
- Analysis visit
- Baseline values
- Change from baseline
- Reference ranges
- High/low flags
- Shift categories
- Clinically important abnormalities
A reusable function can standardize the presentation framework, while study-specific rules for clinically important abnormalities remain explicit.
Shift Tables as a Reusable Pattern
A shift table often maps baseline categories against post-baseline categories:
The reusable function can generate the cross-tabulation, while the study specification determines the category definitions and analysis population.
Reusable Functions for Efficacy Tables
Efficacy TLFs may require:
- Response categories
- Analysis populations
- Time points
- Confidence intervals
- Event-time summaries
- Stratification variables
The function should not infer a response definition from a variable name. The response endpoint should already be derived according to the applicable analysis specification.
Confidence Intervals as a Reusable Component
For example, a reusable binomial confidence-interval helper might receive:
binom_ci <- function(
x,
n,
method = "exact"
) {
...
}
The method should be explicit because different interval methods can produce different results.
This is a good example of a reusable statistical component whose interface should document the methodological choice clearly.
Function Naming Conventions
Names should describe the operation and object type when helpful.
| Weak | Better |
|---|---|
| do_table() | make_demographic_table() |
| helper1() | format_n_pct() |
| process_data() | derive_treatment_emergent_ae() |
| plot_it() | make_spider_plot() |
| clean() | validate_tlf_inputs() |
Good names reduce the amount of documentation required to understand a program.
Keep Functions Short Enough to Understand
There is no magic line-count limit, but a function that requires extensive scrolling is often a sign that responsibilities should be separated.
Ask:
- Can I describe this function's purpose in one sentence?
- Does each section contribute to that purpose?
- Could one section be independently tested?
- Does the function have too many arguments?
- Would a helper function make the main function clearer?
A Practical Refactoring Example
Imagine a 300-line table program containing:
- Population filtering
- Variable derivation
- Statistical summaries
- Formatting
- RTF creation
Refactor it into:
analysis_data <- derive_analysis_data( adsl = adsl, analysis = "safety" ) summary_data <- summarize_categorical( data = analysis_data, variable = "SEX", treatment_var = "TRT01P" ) table_object <- format_categorical_table( summary_data = summary_data ) render_tlf( table_object, output = "Table_14_1_1.rtf" )
The driver becomes much easier to review because it reads like the reporting workflow.
Reusable Functions and Code Review
A reviewer should be able to inspect:
- What data enter the function.
- What assumptions the function makes.
- What transformations occur.
- What values are returned.
- How the output is rendered.
Functions with explicit interfaces make these review questions easier to answer.
Code Review Questions
What Makes a TLF Function "Production Ready"?
A production-ready function is more than a function that works on one dataset. It should have:
- A documented interface
- Input validation
- Defined edge-case behavior
- Predictable return values
- Unit tests
- Regression tests where appropriate
- Version control
- Controlled dependencies
- Traceable assumptions
- Informative diagnostics
Reusable Functions and Regulatory Submissions
For regulatory reporting, reproducibility and traceability are particularly important.
A reusable function library can improve consistency, but the library itself should be governed appropriately. Depending on the organization's processes, this can include documented requirements, testing evidence, version control, change control, and validation documentation.
Function Validation Versus TLF Validation
| Level | Primary question |
|---|---|
| Function test | Does the reusable algorithm behave as designed? |
| Integration test | Does the function work correctly with the analysis dataset and related components? |
| TLF validation | Does this specific table/listing/figure contain the correct clinical result and presentation? |
| Output review | Does the final artifact match the approved specification? |
All four levels can be useful. Passing a unit test does not automatically prove that a particular clinical TLF is correct.
Common Mistakes
- Making functions generic before understanding the repeated pattern. This often produces overcomplicated interfaces.
- Hiding population definitions. Clinically meaningful filters should remain traceable.
- Mixing analysis and formatting. Numeric analysis objects should remain inspectable.
- Using global variables. Hidden dependencies make functions harder to reproduce.
- Using ambiguous arguments. Parameters should have one clear meaning.
- Silently dropping data. Filters and missing-value rules should be intentional.
- Relying on alphabetical order. Clinical reporting often requires controlled order.
- Ignoring empty groups. Planned treatment or category columns may need to remain visible.
- Testing only the happy path. Missing columns, empty data, and zero denominators need tests.
- Changing shared functions without regression testing. One small change can affect many TLFs.
- Returning inconsistent object types. Callers should know what to expect.
- Using the same algorithm for validation. Independent checks are stronger.
- Putting study-specific clinical rules into generic utilities. This can obscure traceability.
- Creating one giant TLF function. Separate derivation, summary, formatting, and rendering.
Recommended Architecture
A Practical Folder Structure
clinical-reporting/ | +-- R/ | +-- validation/ | +-- derivations/ | +-- summaries/ | +-- formatting/ | +-- rendering/ | +-- figures/ | +-- tests/ | +-- testthat/ | +-- specs/ | +-- tables/ | +-- listings/ | +-- figures/ | +-- programs/ | +-- t14_1_1.R | +-- t14_2_1.R | +-- f14_3_1.R | +-- outputs/ | +-- logs/ | +-- renv/
The exact structure should match the organization's development and validation process, but the principle is to separate reusable implementation from study-specific orchestration.
End-to-End Example
Suppose the specification calls for a demographic table containing age, sex, and race by treatment group.
Step 1: Select the Analysis Population
analysis_data <- adsl |> dplyr::filter(SAFFL == "Y")
Step 2: Validate the Dataset
check_required_columns(
analysis_data,
c("USUBJID", "TRT01P", "AGE", "SEX", "RACE")
)
Step 3: Create the Summary
summary_data <- summarize_demographics(
data = analysis_data,
treatment_var = "TRT01P",
continuous_vars = "AGE",
categorical_vars = c("SEX", "RACE"),
include_total = TRUE
)
Step 4: Format the Table
table_object <- format_demographic_table( summary_data = summary_data, digits = 1 )
Step 5: Render
render_tlf( table_object, output = "outputs/Table_14_1_1.rtf" )
Step 6: Validate
validate_tlf( actual = table_object, expected = expected_table_14_1_1 )
The reusable library handles the repeated mechanics while the driver remains specific to the table.
How Reuse Changes the Programming Workflow
| Without reusable functions | With reusable functions |
|---|---|
| Copy an old TLF program | Select a tested function |
| Edit many sections | Supply explicit parameters |
| Repeat formatting logic | Reuse controlled formatting helpers |
| Debug the entire program | Test smaller components |
| Repeat fixes across outputs | Correct shared logic once, then regression-test |
| Harder to compare programs | Consistent architecture across TLFs |
When Reuse Saves the Most Time
Reuse is especially valuable when:
- Many TLFs have similar structures.
- Multiple treatment groups are reported consistently.
- Formatting rules are standardized.
- The same statistical summaries recur.
- Several studies use related reporting conventions.
- TLFs are regenerated frequently during review cycles.
The benefit compounds over time because improvements to tested shared components can reduce future programming effort.
When Not to Reuse a Function
A function may not be appropriate when:
- The logic is genuinely unique.
- Abstracting it would require many special-case arguments.
- The clinical meaning would become less visible.
- The function would be used only once and provide no meaningful testing benefit.
The goal is not maximum reuse. The goal is appropriate reuse.
Checklist for Designing a Reusable TLF Function
TLF Validation Checklist
- Confirm the correct analysis dataset.
- Confirm the correct analysis population.
- Confirm all required variables are present.
- Confirm treatment-group definitions and ordering.
- Confirm statistical definitions.
- Confirm denominators.
- Confirm rounding rules.
- Confirm missing-data handling.
- Confirm row and column order.
- Confirm titles and footnotes.
- Confirm output dimensions and page breaks.
- Compare key values independently.
- Review the final rendered artifact.
- Record the function/package version used for generation.
The Most Important Design Principle
A reusable R function for TLF generation should make the repeated programming pattern reusable while keeping the clinical decision visible.
For example, it is appropriate for a generic function to know how to calculate a mean and standard deviation.
It is much more questionable for that same generic function to silently decide which subjects belong to the safety population, which response definition applies, or which post-baseline assessment should be selected.
Those decisions should be controlled by the study specification, analysis dataset, SAP, or explicit function arguments.
Summary
Reusable R functions can transform clinical-trial TLF programming from a collection of duplicated scripts into a controlled reporting system.
The strongest approach is layered:
- Analysis datasets provide controlled inputs.
- Derivation helpers perform reusable calculations.
- Summary functions calculate recurring statistics.
- Formatting functions standardize presentation.
- Rendering functions produce final artifacts.
- Study-specific drivers expose the actual TLF decisions.
- Tests and validation provide confidence that the shared components behave as intended.
This architecture improves consistency, reduces duplication, makes code review easier, and provides a stronger foundation for scalable clinical reporting.
References
Wickham, H., Averick, M., Bryan, J., et al. (2019).
Welcome to the Tidyverse.
Journal of Open Source Software, 4(43), 1686.
Wickham, H., François, R., Henry, L., Müller, K., & Vaughan, D.
dplyr: A Grammar of Data Manipulation.
R package documentation.
Wickham, H., Bryan, J.
R Packages: Organize, Test, Document, and Share Your Code.
O'Reilly Media.
Wickham, H., Bryan, J.
R for Data Science.
O'Reilly Media.
R Core Team.
R: A Language and Environment for Statistical Computing.
R Foundation for Statistical Computing.
The R Foundation.
R Documentation and Writing R Extensions.