This is a dependency-free experimental implementation of Option & Result types in base R. The API draws most of its inspiration from the Rust standard library (Option, Result<T, E>).
Other packages have done something for Option
(maybe, optional, among others), but I am unaware of any Result
implementations in R (please correct me!).
# install.packages("remotes")
remotes::install_github("snystrom/sometype")
sometype
implements an Option
S3 type:
Options are useful for wrapping values that can be missing (None
) or real (Some
), forcing the user to handle them explicitly.
library(sometype)
div0 <- function(x, y) {
if (y == 0) {
return(none)
}
some(x/y)
}
answer <- div0(10,2)
# This is not allowed
answer > 2
#> Error: Cannot use > on Option<Some>
# Explicitly handling the option allows comparison
unwrap(answer) > 2
#> [1] TRUE
# Syntactic sugar allows auto-unwrapping
# (this is probably a bad idea and I'll kill this later)
answer <- !div0(10,2)
answer == 5
#> [1] TRUE
# Unwrap(none) will crash
answer <- !div0(10,0)
#> Error: Cannot unwrap, got None
Users can manually build Option<T>
objects using some()
and none
, or by using the option()
constructor.
# Values become Some()
option(5)
#> some(numeric)
#> [1] 5
# Missing values become None
option(NULL)
#> None
# Options can be manually constructed
some(5)
#> some(numeric)
#> [1] 5
# none is a special keyword export!
none
#> None
Users can provide custom predicates to produce Option<None>
from values.
gt_five <- function(x) {
x > 5
}
option(10, list(gt_five))
#> None
# Return a default value on None
unwrap_or(div0(1,0), 0)
#> [1] 0
# Do a custom behavior on None
oh_no <- function() {
print("Oh No!")
}
unwrap_or_else(div0(1,0), oh_no)
#> [1] "Oh No!"
# Throw a specific error on None
expect(div0(1,0), "I divided by zero!")
#> Error: I divided by zero!
e <- error("some_error", "a custom message")
e
#> Result<Error>
#> Error<some_error>
#> 'a custom message'
# NOTE: I had to hack RMD to display this suggesting I haven't quite got the error() implementation down yet.
stop(e)
#> Error: a custom message
ok(5)
#> Result<ok(numeric)>
#> [1] 5
# results cannot nest (this differs from Rust!)
ok(ok(5)) == ok(5)
#> [1] TRUE
Results can be unwrapped just like Options.
unwrap(ok(5))
#> [1] 5
ok(5) + 1
#> Error: Cannot use + on Result<Ok>
as.integer(ok(5))
#> Error: Cannot convert Result<Ok> to integer.
But can be compared with other Results
ok(1) == ok(1)
#> [1] TRUE
ok(1) == ok(2)
#> [1] FALSE
ok(1) == 1
#> Error in `==.result`(ok(1), 1): Cannot compare Result<Ok> to non-Result.
error() == error()
#> [1] TRUE
error() == ok(1)
#> [1] FALSE
Use with methods that do not natively support sometype
.
may_fail <- function(x) {
if (x > 10) {
stop("failure!")
} else {
return("success!")
}
}
may_fail(5)
#> [1] "success!"
may_fail(11)
#> Error in may_fail(11): failure!
try_result(may_fail(5))
#> Result<ok(character)>
#> [1] "success!"
try_result(may_fail(11))
#> Result<Error>
#> Error<generic_result_error>
#> 'failure!'
Catch into custom error types
try_result(may_fail(11), .err_type = "a_custom_error")
#> Result<Error>
#> Error<a_custom_error>
#> 'failure!'
Consider a situation where two methods are owned by an external source (method_one
, method_two
), that we wrap into our own handler, nested_may_fail
.
# Pretend this is from another package, we don't control whether it `stop`s
method_one <- function() {
stop("method one failed")
}
# Pretend this is from another package, we don't control whether it `stop`s
method_two <- function() {
stop("method two failed")
}
# This is our function, we only control how to handle the outputs
nested_may_fail <- function(x = TRUE) {
if (x) {
method_one()
} else {
method_two()
}
}
nested_may_fail(TRUE)
#> Error in method_one(): method one failed
nested_may_fail(FALSE)
#> Error in method_two(): method two failed
If we control nested_may_fail
but do not control the implementations of method_one
or method_two
, it is difficult to handle each method failure without writing tryCatch
logic in-place for each method.
handle_method_one_fail <- function(e) {
message('caught failure one')
}
handle_method_two_fail <- function(e) {
message('caught failure two')
}
nested_may_fail <- function(x = TRUE) {
if (x) {
tryCatch(method_one(),
error = handle_method_one_fail
)
} else {
tryCatch(method_two(),
error = handle_method_two_fail
)
}
}
nested_may_fail()
#> caught failure one
Using result
s and error
s allows us to take ownership of errors and centralize how we handle the output.
nested_may_fail <- function(x = TRUE) {
if (x) {
try_result(method_one(), .err_type = "method_one")
} else {
try_result(method_two(), .err_type = "method_two")
}
}
result <- nested_may_fail()
if (is_err(result)) {
switch(result$error_type,
method_one = message("caught failure one"),
method_two = message("caught failure two"),
generic_result_error = stop(result),
stop("Unknown error type")
)
} else {
result <- unwrap(result)
}
#> caught failure one
# Continue with success logic
Experimental: match_result()
This is just syntactic sugar over switch
like above, but supports an ok
entry.
result <- nested_may_fail()
output <- match_result(result,
method_one = message("caught failure one"),
method_two = message("caught failure two"),
generic_result_error = stop(result),
ok = unwrap(result)
)
#> caught failure one
ok_or(some(1))
#> Result<ok(numeric)>
#> [1] 1
ok_or(none)
#> Result<Error>
#> Error<generic_result_error>
#> 'generic_result_error'
as_option(ok(1))
#> some(numeric)
#> [1] 1
as_option(error())
#> None
as_option(
try_result({
stop("oh no!")
})
)
#> None
For better or for worse, R's type system allows amazing flexibility often allowing things to "just work". This however doesn't work well for a data structure (like an option
) that we want to force users to handle.
sometype
's option
s are designed for minimal compatability with the rest of the R ecosystem. The goal is that users must handle options
before actual work can be done on them. Other packages do not implement this behavior.
To demonstrate:
optional_five <- optional::option(5)
just_five <- maybe::just(5)
some_five <- sometype::some(5)
optional
propagates the option
type, but allows computation.
optional_five + 1
#> [1] 6
optional::none + 1
#> [1] "None"
maybe
errors on some operations.
# This errors! Good!
just_five + 1
#> Error in just_five + 1: non-numeric argument to binary operator
But supports others:
# Oh no!
just_five[1]
#> $type
#> [1] "just"
# Oh no!
as.character(just_five)
#> [1] "just" "5"
sometype
should fail on all base R operations
some_five + 1
#> Error: Cannot use + on Option<Some>
some_five[1]
#> Error: Cannot use [ on Option<Some>
as.character(some_five)
#> Error: Cannot convert Option<Some> to character.
If an option
or result
can be provided as a valid argument to a function that does not handle them and produce no errors: that's probably a bug.
- Still some weirdness with the
error
impl, it could be better. (should we store & throw the original condition somehow?) - I'm not sold whether S3 is the right impl. May test an R6 version so it is more clear what methods are allowed on Result vs Option, etc.
- should
match_result
support generic error fallback?