R Client User Interface Design
To provide better user experience with synapser, several interface patterns are being considered for exposing model class methods in the Synapse R client, including entities such as File, Project, and Table. The goal is to identify the design that is most natural and idiomatic for R users.
These proposals focus only on model-specific methods. High-level CRUD operations such as get, store, and delete would continue to dispatch automatically to the appropriate entity type, so users would not need to call entity-specific CRUD methods directly.
Decision Framing
What is being decided: How model class methods should be exposed in the Synapse R client.
Why this matters: This choice will affect usability, consistency, discoverability, documentation quality, and how well the API aligns with common R conventions.
Evaluation Criteria
Idiomatic for R users
Easy to discover via tab completion, hovering, and documentation
Clear separation of constructor arguments and method arguments
Scales well as entities gain more fields and methods
Supports readable workflows for common operations
Options Considered
Option | Summary | Strengths | Risks / Drawbacks | Overall Assessment |
|---|---|---|---|---|
Instance-First with Pipe | User constructs or retrieves an object and pass it through the pipe and applying methods directly to it |
| Best fit may depend on how broadly pipe-oriented workflows are expected across the client. | Most promising balance of clarity, scalability, and R ergonomics. |
Explicit Instance Parameter | User constructs an object first, then passes it as the first positional argument to the method. |
|
| Reasonable and conventional, but somewhat verbose. |
Fully Flattened Functional Interface | Generated function constructs the object internally; user passes only raw arguments. |
| Large signatures; constructor and method parameters get mixed together; weaker discoverability. | Easy at first glance, but likely hard to scale cleanly. |
Detailed Option Analysis
Option 1: Instance-First with Pipe
In this approach, the object is passed through the pipe and methods operate on it. This mirrors tidyverse conventions, where data flows left to right and each verb declares only its own parameters.
Reference: purrr documentation
Example: version a table
Table(id = "syn1234") |>
synSnapshot(comment = "test_2_pipe")Example: query a table
synQuery(query = "SELECT * FROM syn1234")Example: update table annotations with a helper
synGet(synapse_id = "syn1234") |>
synSetProperties(
annotations = list(
first_annotation = "value",
second_annotation = "value2"
)
) |>
synStore()Example: upload a file
synStore(
File(
path = "...",
parent_id = "syn1234"
)
)Alternative upload form
File(
path = "...",
parent_id = "syn1234"
) |>
synStore()Example: download a file
synGet(
synapse_id = "syn1234",
path = "...",
file_options = FileOptions(download_file = TRUE)
)Alternative download form
File(
id = "syn1234",
path = "..."
) |>
synGet(
file_options = FileOptions(download_file = TRUE)
)Pros
Aligns with tidyverse and pipe-oriented workflows.
Keeps function signatures short and discoverable.
Constructor parameters remain separate from method parameters.
Feels natural to many R users already familiar with
%>%and|>.Scales effectively as more methods or models are added.
Additional benefit
If we adopt this pattern, we don’t need entity-specific methods such as synSnapshotTable() and synSnapshotDataset(). Because the entity type is already known from the constructed object, a generic synSnapshot() could potentially handle all supported entity types.
Option 2: Explicit Instance Parameter
In this approach, the user first constructs an object and then passes it as an argument to the method. This is similar to R's S3 method style, where the model object is passed first and dispatch is based on class.
Reference: predict.lm documentation
Example: version a table
synSnapshotTable(
instance = Table(id = "syn1234"),
comment = "test_2_pipe"
)Example: update table annotations
table <- synGet(synapse_id = "syn1234")
table$annotations <- list(
first_annotation = "value",
second_annotation = "value2"
)
synStore(entity = table)Example: upload a file
synStore(
entity = File(
path = "...",
parent_id = "syn1234"
)
)Example: download a file
synGet(
synapse_id = "syn1234",
path = "...",
file_options = FileOptions(download_file = TRUE)
)Pros
Familiar object-oriented pattern.
Similar to existing S3 method conventions.
Cons
Requires users to explicitly construct objects before calling methods.
May feel less natural for some R users.
Option 3: Fully Flattened Functional Interface
In this approach, the generated function constructs the object internally and then invokes the method. Users never interact directly with the underlying object instance.
Example
synSnapshotTable(
id = "syn1234",
comment = "test"
)Illustrative expanded signature
synSnapshotTable(
id = NULL,
name = NULL,
parent_id = NULL,
activity = NULL,
...
comment = NULL,
timeout = 120L,
synapse_client = NULL
)Pros
Simple for straightforward use cases.
No explicit object construction required.
Cons
Function signatures can become very long.
Constructor and method arguments are mixed together.
Harder to discover and understand through tab completion and documentation.
Trade-off Summary
Key trade-off: The main tension is between minimizing upfront object construction for simple calls and preserving a clean, scalable, and understandable API as entity complexity grows.
Option 1 appears to offer the best balance between clarity, composability, and API scalability.
Option 2 favors explicitness and convention.
Option 3 favors convenience, but risks bloated interfaces.
Current Recommendation
Proposed direction for discussion: Option 1, Instance-First with Pipe.
This option seems strongest because it keeps constructor concerns separate from method concerns, supports concise signatures, aligns with modern R workflows, and may allow us to consolidate entity-specific methods into more generic verbs.
Questions for Reviewers
Which option feels most natural from an R user's perspective?
Are there cases where pipe-based method calls would be awkward or unclear?
How important is alignment with classic S3-style conventions versus tidyverse-style workflows?
Would generic verbs such as
synSnapshot()improve the API, or make it less explicit?Are there alternative patterns we should consider before making a decision?