Tutorials › Biostatistics › Writing Reusable R Functions for TLF Generation

R Programming for Clinical Trials

Writing Reusable R Functions for TLF Generation

A practical guide to designing reusable R functions for clinical-trial Tables, Listings, and Figures (TLFs), including function interfaces, parameterization, validation, formatting, debugging, testing, traceability, and production-ready reporting workflows.

Intermediate 24 min read

What You'll Learn

  • Why reusable functions matter in clinical-trial TLF programming
  • How to design stable function interfaces and arguments
  • How to parameterize tables, listings, and figures
  • How to separate analysis logic from presentation logic
  • How to validate inputs, fail safely, and test reusable functions
  • How to build a scalable TLF generation architecture

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.

Key idea: A reusable TLF function should encapsulate a stable piece of reporting logic while exposing the study-specific decisions as explicit arguments. The goal is not merely to make code shorter. The goal is to make reporting behavior consistent, inspectable, testable, and maintainable.

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:

1
Analysis: determine the observations, populations, summaries, and derived values that belong in the output.
2
Presentation: arrange rows and columns, labels, statistics, headers, titles, and footnotes.
3
Delivery: render the result to RTF, PDF, HTML, Excel, or another controlled output format.

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

Figure 1. Conceptual architecture of a reusable TLF function
Study-specific inputs flow into a controlled reporting function, which returns a predictable artifact.
Inputs ADaM / SDTM data population variables formatting options Validation required columns types / values argument checks Analysis filter population derive summaries assemble structure TLF output table / listing / figure validated object rendered artifact one stable interface • many TLFs

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.

Practical rule: First write two or three concrete TLFs. Identify the repeated logic. Then extract that logic into a function. Reusability should emerge from a known pattern rather than from speculative generalization.

What Belongs in a Function Argument?

A good function argument represents a decision that legitimately changes between calls.

CandidateUsually an argument?Reason
Input datasetYesThe same function may operate on multiple analysis datasets.
Population filterYesDifferent TLFs may use different analysis populations.
Analysis variableYesThe summary may be applied to several variables.
Treatment variableOftenStudy conventions may differ across programs or domains.
Output pathSometimesUseful for rendering functions, less useful for pure analysis functions.
Fixed clinical ruleUsually noRules specified by the SAP should not be casually overridden.
Internal temporary variableNoIt 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.

Production principle: A TLF function should ideally behave like a controlled transformation: given the same input data, arguments, package versions, and configuration, it should produce the same result.

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)
  )
}
Why this matters: Clinical datasets contain legitimate edge cases. A reusable function should define how those cases are represented instead of allowing base R warnings or errors to determine the reporting behavior accidentally.

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:

$$ \text{Percentage} = \frac{\text{Number of subjects with characteristic}} {\text{Number of subjects in the applicable denominator}} \times100 $$

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.

Traceability principle: The reviewer should be able to identify where the analysis population was defined. A reusable function should not make clinically meaningful inclusion rules invisible.

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.

A
derive_tlf_data() — derives the analysis-ready observations.
B
summarize_tlf() — calculates the requested summaries.
C
format_tlf() — applies labels, display strings, and table structure.
D
render_tlf() — writes the final document or figure.

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:

$$ n(\%) = \frac{n_{\text{subjects with event}}} {N_{\text{subjects in treatment group}}} \times100 $$

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 typeGood return value
DerivationAnalysis-ready data frame
SummaryStructured summary data frame or list
FormattingFormatted table object
ValidationLogical result, diagnostic object, or informative error
RenderingFile 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.

Common failure: A table can be numerically correct but still be wrong for reporting if rows or columns appear in the wrong order. Reusable functions should make ordering explicit rather than relying on incidental sorting behavior.

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 candidateWhy
Variable nameControls which analysis variable is summarized.
LabelControls display text.
OrderControls row or column placement.
Statistic listControls requested summaries.
FormatControls numeric display.
Population flagCan 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:

Validate both content and presentation. A TLF is not correct merely because its underlying dataset is correct. The displayed structure is part of the deliverable.

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 libraryStudy-specific driver
Generic summary algorithmsWhich population is used
Formatting utilitiesSpecific table number and title
Validation helpersSpecific dataset
Common rendering conventionsSpecific variables
Shared plot constructionStudy-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.

Clinical-programming principle: The more TLFs depend on a reusable function, the more important its interface stability and regression testing become.

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:

1
Low-level helpers — formatting, validation, denominators, labels.
2
Pattern functions — continuous summaries, categorical summaries, listings, longitudinal plots.
3
TLF-specific wrappers — study-specific combinations with stable interfaces.
4
Driver programs — select data, parameters, and output destinations.

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.

$$ \text{Input Contract} \rightarrow \text{Validated Processing} \rightarrow \text{Output Contract} $$

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.

Important distinction: A reusable programming function standardizes implementation. It does not replace the study's statistical analysis plan, TLF shell, data standards, or controlled derivation specifications.

TLF Shells and Reusable Functions

A useful workflow is:

1
Define the TLF shell and required content.
2
Identify the analysis dataset and variables.
3
Identify repeated implementation patterns.
4
Implement or reuse tested functions for those patterns.
5
Parameterize the study-specific decisions.
6
Generate the TLF.
7
Validate both the data and the rendered artifact.

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 layerExample question
Input validationAre required variables present?
DerivationAre analysis values correct?
SummaryAre counts, percentages, and statistics correct?
OrderingAre treatment groups and rows in the correct order?
FormattingAre values displayed according to the specification?
RenderingDoes the final file match the intended layout?
RegressionDid 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:

$$ \text{Treatment} \rightarrow \text{System Organ Class} \rightarrow \text{Preferred Term} $$

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.

Do not confuse "no observations" with "drop the category." The reporting specification determines whether an empty treatment group remains in the output.

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

$$ PCHG = \frac{AVAL-BASE}{BASE}\times100 $$
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:

$$ \text{Baseline Category} \times \text{Post-Baseline Category} $$

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.

WeakBetter
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:

  1. What data enter the function.
  2. What assumptions the function makes.
  3. What transformations occur.
  4. What values are returned.
  5. How the output is rendered.

Functions with explicit interfaces make these review questions easier to answer.

Code Review Questions

1
Are all clinically meaningful inputs explicit?
2
Are defaults appropriate and documented?
3
Are missing values handled intentionally?
4
Are treatment and row orders controlled?
5
Are statistical calculations separated from formatting?
6
Are errors informative?
7
Are edge cases tested?
8
Can the output be independently validated?

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.

Important: Reusability is not the same as validation. A function used in a regulated environment should be subject to the organization's applicable software and computerized-system controls.

Function Validation Versus TLF Validation

LevelPrimary question
Function testDoes the reusable algorithm behave as designed?
Integration testDoes the function work correctly with the analysis dataset and related components?
TLF validationDoes this specific table/listing/figure contain the correct clinical result and presentation?
Output reviewDoes 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

  1. Making functions generic before understanding the repeated pattern. This often produces overcomplicated interfaces.
  2. Hiding population definitions. Clinically meaningful filters should remain traceable.
  3. Mixing analysis and formatting. Numeric analysis objects should remain inspectable.
  4. Using global variables. Hidden dependencies make functions harder to reproduce.
  5. Using ambiguous arguments. Parameters should have one clear meaning.
  6. Silently dropping data. Filters and missing-value rules should be intentional.
  7. Relying on alphabetical order. Clinical reporting often requires controlled order.
  8. Ignoring empty groups. Planned treatment or category columns may need to remain visible.
  9. Testing only the happy path. Missing columns, empty data, and zero denominators need tests.
  10. Changing shared functions without regression testing. One small change can affect many TLFs.
  11. Returning inconsistent object types. Callers should know what to expect.
  12. Using the same algorithm for validation. Independent checks are stronger.
  13. Putting study-specific clinical rules into generic utilities. This can obscure traceability.
  14. Creating one giant TLF function. Separate derivation, summary, formatting, and rendering.

Recommended Architecture

Figure 2. A scalable TLF programming architecture
Reusable components form a controlled middle layer between analysis datasets and study-specific TLF drivers.
Analysis datasets Derivation helpers flags • changes • endpoints Summary functions n • % • means • medians Formatting / rendering labels • layouts • files Study-specific TLF drivers table shells • populations • variables • titles • output destinations

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 functionsWith reusable functions
Copy an old TLF programSelect a tested function
Edit many sectionsSupply explicit parameters
Repeat formatting logicReuse controlled formatting helpers
Debug the entire programTest smaller components
Repeat fixes across outputsCorrect shared logic once, then regression-test
Harder to compare programsConsistent 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

1
Identify a repeated reporting pattern.
2
Define the function's purpose in one sentence.
3
List required and optional inputs.
4
Separate clinical decisions from implementation details.
5
Validate required columns and argument values.
6
Define missing-data and edge-case behavior.
7
Return a predictable structured object.
8
Write unit tests for normal and boundary cases.
9
Add regression tests before changing shared behavior.
10
Keep study-specific choices visible in the driver.

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.

Bottom line: The best reusable TLF functions are not the ones with the most parameters or the fewest lines of code. They are the ones with a clear contract, explicit inputs, controlled assumptions, predictable outputs, strong tests, and a clean separation between clinical analysis decisions and repeated programming mechanics.

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.

Remember: Write reusable functions around stable patterns, not around the desire to make everything generic. Keep clinical definitions explicit, keep analysis values numeric until the presentation layer, validate inputs early, test edge cases, and treat shared-function changes as controlled changes because one function may influence many TLFs.

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.