posit-dev / posit-dev/shinyreact
Typed contract: Python-defined data models as the single source of truth
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 14
- Forks
- 3
- Avg merge
- 9h 12m
- Merged PRs (30d)
- 74
Description
Typed contract: Python-defined data models as the single source of truth
The problem
When a shinyreact app breaks, the developer has no idea whether the bug is in their Python, their React, or the invisible string-matching that connects them.
Today, the connection between Python server functions and React components is a bare string — "scatter_data", "user_text". Nothing checks that the strings match. Nothing checks that the data shapes agree. A typo on either side produces silence, not an error.
Concrete DX gaps
1. No contract between Python and JS. A rename from scatter_data to scatter_plot_data in Python silently breaks useShinyOutput("scatter_data") in React — no error, just a component stuck at its default value forever.
2. No types across the bridge. useShinyOutput("scatter_data", null) returns any. The developer must read the Python source to know the shape is { age: number[], score: number[] }. The Python side has no way to declare what shape reactive_output produces — Jsonifiable is the entire type.
3. No "wiring diagram." In traditional Shiny, you read the UI function top-to-bottom and see the layout with output placeholders inline. In shinyreact's SPA model, the Python server is a flat list of functions with no spatial relationship to the React components that consume them. The only way to understand the wiring is to grep both codebases for matching strings.
4. Default values are load-bearing but unvalidated. Every useShinyOutput call requires a defaultValue that the component renders before the server responds. There's no way to distinguish "hasn't loaded yet" from "server returned the default," and no validation that the server's response matches what the component expects.
The core insight
All of these problems stem from the same root cause: the data contract between Python and React is implicit. It exists only as string literals scattered across two codebases in two languages. The framework has no model of what should flow between them, so it can't validate, type-check, generate code, or provide diagnostics.
Proposal: Python models as the shared contract
The developer declares their inputs and outputs as typed Python models. The framework enforces the contract, generates TypeScript types, and makes the wiring visible.
The source of truth lives in Python because that's where app developers spend most of their time. TypeScript is derived, not authored.
User API
Inputs: a typed Python class
from shinyreact import InputModel, Input
class Inputs(InputModel):
user_text = Input("")
button_trigger = Input(0, priority="event")
tags = Input[list[str]]([])
inputs = Inputs()
Each field declares a name (from the attribute), a type (inferred from the default or specified explicitly), and a default value. Reading an input registers a reactive dependency:
inputs.user_text() # -> str, registers reactive dependency
inputs.user_text.get() # equivalent
For complex types where inference isn't sufficient:
Input[list[str]]([]) # explicit generic
Input([], type=list[str]) # equivalent with type= kwarg
Input options like priority are co-located with the declaration:
Input(0, priority="event") # not scattered across hook calls
Implementation: descriptors and reactive values
Input is a Python descriptor. At the class level it stores metadata (default, type, priority). At the instance level, __get__ returns a _BoundInput that wraps a reactive.Value.
The descriptor approach gives correct types with no special tooling. Input("") constructs Input[str], pyright infers T=str, and __get__ returns Input[T] — so inputs.user_text is Input[str], inputs.user_text() returns str, and inputs.user_text.update(...) accepts str.
InputModel.__init_subclass__ collects all Input descriptors and builds a pydantic model via create_model(). This model serves two purposes:
- Validation —
model_validate(raw_dict)validates incoming client values with full pydantic error messages. - JSON Schema / codegen —
model_json_schema()produces the complete schema for TypeScript generation.
At instance creation, each descriptor gets a _BoundInput + reactive.Value pair. reactive.Value(default, read_only=True) prevents direct .set() calls — the framework uses ._set() internally when client values arrive.
Updating inputs from the server
The server can request client-side input changes, mirroring Shiny's update_* pattern:
inputs.user_text.update("new value")
This sends a message to the React client, which updates its component state. The server is requesting a change, not directly mutating — the client remains the source of truth.
Implementation: update mechanism
When inputs.foo.update("new") is called:
- Python sends via
send_message(session, "__update_input:foo", "new"). - This calls
session.send_custom_message("shinyReactMessage", {"type": "__update_input:foo", "data": "new"}). - On the JS side, a framework-level handler receives
__update_input:*messages and routes them to the input registry, callingsetValueon the corresponding hook's state.
For generated TypeScript hooks, the update handler is wired in automatically:
export function useUserText(): [string, (value: string) => void] {
const [value, setValue] = useShinyInput<string>("user_text", "");
useShinyMessageHandler("__update_input:user_text", (data: string) => {
setValue(data);
});
return [value, setValue];
}
Outputs: typed return values
from pydantic import BaseModel
class ScatterData(BaseModel):
age: list[float]
score: list[float]
@reactive_output
def scatter_data() -> ScatterData:
return ScatterData(age=[25, 30, 35], score=[85.5, 92.1, 88.3])
The function name is the output ID. The return type is the data contract. If the function returns data that doesn't match the model, it fails loudly in Python — not silently in the browser.
For simple scalar outputs, no model class is needed:
@reactive_output
def processed_text() -> str:
return inputs.user_text().upper()
Implementation: output validation
reactive_output introspects the decorated function's return type annotation at decoration time. When the return type is a pydantic BaseModel subclass, the renderer validates the return value before sending it to the client:
class reactive_output(Renderer[Jsonifiable]):
def __init__(self, fn):
super().__init__(fn)
hints = get_type_hints(fn)
ret = hints.get("return")
self._return_model = ret if (ret and issubclass(ret, BaseModel)) else None
async def transform(self, value: Jsonifiable) -> Jsonifiable:
if self._return_model is not None:
value = self._return_model.model_validate(value)
return value.model_dump()
return value
For non-BaseModel return types (e.g., -> str, -> int), pydantic's TypeAdapter provides the same validation. This is a small, self-contained change to reactive_output that can be implemented independently of the input model work.
Namespacing
Inputs and outputs share a namespace scope via a context manager:
from shinyreact import namespace
def my_module(ns: str):
with namespace(ns):
inputs = MyInputs() # IDs become "mod1-user_text", etc.
@reactive_output
def scatter_data(): ... # output ID becomes "mod1-scatter_data"
Nesting composes naturally:
with namespace("outer"):
with namespace("inner"):
inputs = MyInputs() # IDs: "outer-inner-user_text", etc.
Implementation: ContextVar-based namespacing
A ContextVar stores the active namespace. Both InputModel.__init__ and reactive_output read it at definition time (while the with block is active):
_current_namespace: ContextVar[str | None] = ContextVar("_current_namespace", default=None)
@contextmanager
def namespace(ns: str):
parent = _current_namespace.get()
full_ns = f"{parent}-{ns}" if parent else ns
token = _current_namespace.set(full_ns)
try:
yield
finally:
_current_namespace.reset(token)
The - separator matches Shiny's existing module namespace convention. Integration with Shiny's module.resolve_id() and ModuleSession infrastructure needs to be resolved during implementation.
The full picture
A complete app reads top-to-bottom as: here are my inputs, here are my outputs.
from pydantic import BaseModel
from shinyreact import InputModel, Input, reactive_output
from shiny import reactive
# What the client sends
class Inputs(InputModel):
user_text = Input("")
button_trigger = Input(0, priority="event")
inputs = Inputs()
# What the server sends back
class ScatterData(BaseModel):
age: list[float]
score: list[float]
@reactive_output
def scatter_data() -> ScatterData:
return ScatterData(age=[25, 30, 35], score=[85.5, 92.1, 88.3])
@reactive_output
def processed_text() -> str:
return inputs.user_text().upper()
@reactive_output
@reactive.event(inputs.button_trigger, ignore_init=True)
def button_response() -> str:
return f"Clicked {inputs.button_trigger()} times"
No app = ..., no server function, no wiring boilerplate.
TypeScript side: generated, not hand-written
From the Python models, a CLI command (shinyreact codegen app.py) generates typed React hooks:
// auto-generated from Python models
export function useScatterData() {
return useShinyOutput<{ age: number[]; score: number[] }>("scatter_data", null);
}
export function useUserText(): [string, (value: string) => void] {
return useShinyInput<string>("user_text", "");
}
export function useButtonTrigger(): [number, (value: number) => void] {
return useShinyInput<number>("button_trigger", 0, { priority: "event" });
}
The React developer imports useScatterData() instead of writing useShinyOutput("scatter_data", null). No string IDs to get wrong. Full autocomplete. Types enforced on both sides.
Implementation: codegen pipeline
Every contract has a pydantic model behind it:
- Inputs:
InputModel._pydantic_model, built automatically by__init_subclass__. - Outputs: the user's
BaseModelsubclass from thereactive_outputreturn type annotation.
Both expose .model_json_schema(), so the codegen pipeline is uniform: discover models → call model_json_schema() → convert JSON Schema to TypeScript.
The JSON Schema → TypeScript mapping:
| JSON Schema | TypeScript |
|---|---|
{"type": "string"} |
string |
{"type": "integer"} / {"type": "number"} |
number |
{"type": "boolean"} |
boolean |
{"type": "array", "items": ...} |
T[] |
{"type": "object", "properties": ...} |
Interface with named fields |
{"anyOf": [..., {"type": "null"}]} |
T | null |
Existing tools like json-schema-to-typescript could handle this, or we write a minimal converter tuned to pydantic's output subset.
What this unlocks
| Today | Proposed |
|---|---|
Inputs are untyped — input.user_text() returns Any |
Inputs are typed — inputs.user_text() returns str |
Outputs are untyped — reactive_output accepts any dict |
Outputs are typed — reactive_output validates against the model |
| No way to update inputs from the server | inputs.user_text.update("new") sends update to React client |
| Connection is invisible string matching | Connection is an explicit, inspectable contract |
| Typos produce silence | Typos produce errors (Python) or red squiggles (TypeScript) |
| Data shape documented nowhere | Data shape documented in the model, enforced at runtime |
Beyond correctness, the contract enables dev-mode diagnostics: the framework knows what should exist on both sides. It can warn at startup: "Output scater_data has no subscriber — did you mean scatter_data?" It can print a table of all inputs/outputs, their types, and their status — the "wiring diagram" that's currently invisible.
Scope and future directions
This proposal targets the use case where UI lives primarily in TypeScript (index.tsx) and Python defines the data/logic layer. The typed contract makes this story significantly more usable by eliminating the string-matching guesswork and providing end-to-end type safety.
However, the same foundational ideas — typed input models, validated output schemas, namespace scoping, codegen from Python declarations — could also serve a more "batteries included" approach where a Python UI toolkit generates the React components. In that world, the Python models would drive both the server logic and the UI generation, making the contract even more central. The InputModel / reactive_output typing infrastructure proposed here would be the foundation that a higher-level Python UI layer builds on top of.
For example, a component library could subclass Input to add UI semantics, and Shiny UI functions could return instances of these classes:
from shinyreact import Input
class TextInput(Input[str]):
def __init__(self, default: str = "", *, label: str = "", placeholder: str = ""):
super().__init__(default)
self.label = label
self.placeholder = placeholder
class SliderInput(Input[float]):
def __init__(self, default: float, *, label: str = "", min: float = 0, max: float = 100, step: float = 1):
super().__init__(default)
self.label = label
self.min = min
self.max = max
self.step = step
class Inputs(InputModel):
user_text = TextInput(label="Enter text", placeholder="Type here...")
temperature = SliderInput(0.7, label="Temperature", min=0, max=1, step=0.1)
inputs = Inputs()
inputs.user_text() # -> str, same typed reactive API as before
inputs.temperature() # -> float
The subclass carries enough metadata for the framework to generate the corresponding React component automatically — the developer never writes JSX for standard inputs. Custom/novel UI still uses the base Input() + hand-written TypeScript path from this proposal.
R implementation
The same architecture applies to R. R doesn't have pydantic or generics, but the ellmer package provides a composable type_*() API (type_string(), type_integer(), type_object(), etc.) for type specifications that already produce JSON Schema. An R InputModel could draw inspiration from this vocabulary:
inputs <- input_model(
user_text = input(type_string(), default = ""),
count = input(type_integer(), default = 0L),
button = input(type_integer(), default = 0L, priority = "event")
)
inputs$user_text() # read (reactive)
inputs$user_text$update("new") # update
This wouldn't provide static type checking (R doesn't have that infrastructure), but it would provide runtime validation of client values and feed the same TypeScript codegen pipeline — the generated hooks are identical regardless of whether the server is Python or R.
Implementation order
- Output validation — add pydantic return type introspection to
reactive_output. Smallest change, highest immediate value, no new API surface. Input+InputModel— core descriptor API. Verify types with pyright. Unit test reactive reads.- Session binding — wire
InputModelintoSpaApp's session lifecycle. Resolve multi-session semantics. update()mechanism — Python → JS round-trip viasend_message+useShinyMessageHandler.namespace()context manager — ContextVar-based ID prefixing with nesting support.- TypeScript codegen — CLI tool generating typed hooks from model metadata.
- Input validation — pydantic
model_validate()on incoming client data.
Multi-session considerations
When multiple browser sessions connect to the same app, each needs its own reactive.Value instances. Two approaches:
- Clone on session start —
_bind_inputscreates fresh values per session; module-levelInputModelacts as a template. - Session-keyed storage —
_BoundInput.__call__looks up the current session's value dynamically (closer to how Shiny'sInputsclass works internally).
To be resolved during implementation of step 3.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by mapping the proposed InputModel, reactive_output, namespace, client update handling, and codegen CLI entry points. Break the work into independently scoped areas and define acceptance checks for validated Python contracts, generated TypeScript hooks, namespaced IDs, and typed input/output behavior before implementation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, react, typescript
- Domain
- backend-api-design, developer-experience, full-stack, tooling
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100