How to type Shiny apps
Shiny's reactive graph — reactive(), input/output/session, modules — is
built on non-standard evaluation, the same way dplyr is. This guide applies the
same interop pattern as Use dplyr/tidyr from TypR: keep the
reactive plumbing in R {} blocks, and move the business logic it calls into
typed functions.
The core pattern: typed logic, untyped glue
A typical Shiny server pulls a reactive value and renders it:
server <- function(input, output) {
team <- reactive(fetch_members(input$team_id))
output$roster <- renderTable({
# Which columns does `team()` have?
# Grep the whole app, or run it and find out.
team()[team()$active, ]
})
}The question in the comment — what shape is team()? — is exactly what a
type answers. Pull the filtering logic out into a typed function and call it
from inside the R {} block that holds the reactive glue:
type User <- list { id: int, name: char, active: bool };
let active_only <- fn(members: [Any, User]): [Any, User] {
members.filter(\(u) u$active)
};
let server <- function(input, output) {
R {
team <- shiny::reactive(fetch_members(input$team_id))
output$roster <- shiny::renderTable({
active_only(team())
})
}
};server itself stays an untyped function(...) — Shiny calls it with
input/output (and session, for modules), which are opaque R objects with
no fixed shape. active_only is where the type lives: [Any, User] says
exactly what team() must hold before you write u$active, and the compiler
checks the body against it.
Typing what a reactive carries
The pattern above hides the reactive's type inside the R {} block. When a
reactive's shape needs to flow through more of the program — as a function
argument or return type — declare it with opaque and @extern:
type User <- list { id: int, name: char, active: bool };
opaque Reactive<T> <- Any;
@extern shiny::reactive: (() -> [Any, User]) -> Reactive<[Any, User]>;
let team: Reactive<[Any, User]> <- reactive(fn(): [Any, User] { [] });Reactive<T> is opaque for the same reason R6Person is opaque in the
R6/S4 guide — it is an R closure under the hood, and TypR
does not try to model closures-as-values. What you get instead is a type-level
label: a function that returns Reactive<[Any, User]> promises its caller a
reactive over users, without exposing Any.
Reading a reactive's value in typed code
Calling a reactive (team()) invokes an R closure — the same situation as
calling an R6 method. Use extern with a raw R body, exactly as the R6 guide
does for p$greet():
# --- setup, from the previous block ---
type User <- list { id: int, name: char, active: bool };
opaque Reactive<T> <- Any;
@extern shiny::reactive: (() -> [Any, User]) -> Reactive<[Any, User]>;
let team: Reactive<[Any, User]> <- reactive(fn(): [Any, User] { [] });
# ---------------
let read_reactive <- extern (r: Reactive<[Any, User]>) -> [Any, User] r#"r()"#;
let active_only <- fn(members: [Any, User]): [Any, User] {
members.filter(\(u) u$active)
};
let roster <- fn(): [Any, User] {
active_only(read_reactive(team))
};read_reactive is checked at the boundary — its signature says a Reactive<[Any, User]>
goes in and a [Any, User] comes out — while the call itself (r()) is
emitted verbatim, since "invoke this closure" is not something TypR's type
system represents directly.
Typing Shiny modules
A Shiny module is a pair of functions — a UI function and a server function —
conventionally sharing an id. Keep their internals in R {} blocks (module
UI is built from tagList/NS, both NSE-heavy), and type only the boundary:
the id going in, and the reactive the server function hands back to its
caller.
type User <- list { id: int, name: char, active: bool };
opaque Reactive<T> <- Any;
let user_module_ui <- fn(id: char): Foreign<Any> {
R {
ns <- shiny::NS(id)
shiny::tagList(
shiny::textInput(ns("name"), "Name"),
shiny::actionButton(ns("submit"), "Add")
)
}
};
let user_module_server <- fn(id: char): Reactive<[Any, User]> {
R {
shiny::moduleServer(id, function(input, output, session) {
shiny::reactive({
list(list(id = 1L, name = input$name, active = TRUE))
})
})
}
};Callers of user_module_server now get a checked return type instead of an
opaque list result — user_module_server("users") is a
Reactive<[Any, User]>, and read_reactive (above) is what turns it into
data.
Best practices
-
Type the logic, not the plumbing —
input,output,session, andreactive()'s returned closures are NSE by design. Keep them inR {}blocks and move filtering/computing/validating logic into typed functions. -
Use
opaque Reactive<T>, notForeign<Any>, once a reactive crosses a function boundary — a return type ofReactive<[Any, User]>tells a caller what the reactive holds;Foreign<Any>does not. -
Use
externto invoke,@externto declare —@extern shiny::reactive: ...describes an existing R function's signature;extern (...) -> T r#"..."#wraps a small raw-R expression (like calling a reactive) with a checked type. -
Keep module boundaries small — type the
idparameter and the reactive a module server returns; leavetagList/moduleServerwiring insideR {}, the same way the R6 guide leaves R6 construction insideextern/R {}.
Where to go next
- Use dplyr/tidyr from TypR — the same
R {}/@externpattern applied to the tidyverse - Interop with R6/S4/RC —
opaquewrappers andexternfor method calls, the same idiom used here for reactives - Escape Hatches —
R {},extern,Foreign<T>reference