maxveldink / sorbet-result

Adds T::Result as a basic, strongly-typed monad
MIT License
23 stars 2 forks source link

feat: implement `Result#either` for easier result unpacking #55

Closed maxveldink closed 8 months ago

maxveldink commented 8 months ago

This is largely inspired by Dry Monad's either implementation on their Result type.

This method accepts two procs and runs the first if the result is a success and the second if it's a failure. nil payloads and errors are not passed to the given procs.

One downside is the runtime type erasure of the generic parameters that each proc returns. I'm going to add an issue to investigate if we can prevent the runtime return type to be seen as T.untyped. Maybe @iMacTia has some ideas for how to improve that 😄

iMacTia commented 8 months ago

One downside is the runtime type erasure of the generic parameters that each proc returns.

I was gonna raise exactly that, I remember when I tried implementing either some time ago I had exactly this issue. The implementation looks fine, but I can't see what the static/runtime types are without a test_data test. Do you think you could add one there so if we can figure a way to make this work nicely later we can improve those?

maxveldink commented 8 months ago

Yep! I can add one back. I had a working one, but it seemed to be testing exactly what they regular minitest test case was, so I removed it. I'll push one in a few and see if we can figure something out

maxveldink commented 8 months ago

@iMacTia I just pushed up a first attempt at a typecheck test. The latter two test methods are where I'm having issues/I don't think we get full type checking from Sorbet.

iMacTia commented 8 months ago

Yeah so I spent some time looking into this, and the issue is that when you define a proc using proc { ... } or -> { ... }, sorbet treats them as params(T.untyped).returns(T.untyped). And because of the Parametrised definition of either, that means the result of either is also T.untyped. See example in the sorbet playground.

Now, there's nothing we can do apparently "inside" the procs, that's a limitation of sorbet, but based on how you pass them to either, you can at least get a proper return type. See this example:

sig { params(should_succeed: T::Boolean).returns(Typed::Result[Integer, String]) }
def do_something(should_succeed)
  if should_succeed
    Typed::Success.new(123)
  else
    Typed::Failure.new("error")
  end
end

sig { returns(T.proc.params(arg0: Integer).returns(String)) }
def on_success
  ->(payload) { "Payload was #{payload}" }
end

sig { returns(T.proc.params(arg0: String).returns(String)) }
def on_failure
  ->(error) { raise error }
end

sig { returns(String) }
def test_return_type_check
  res = do_something(true).either(
    on_success,
    on_failure
  )

  T.assert_type!(res, String)
end

At least this way, sorbet knows that res has type String. The trick is using HOFs (on_success, on_failure) to provide a type for the procs.

My suggestion is to clearly spell this out in the README, together with code examples. Personally, I'd discourage using either and instead favour and_then and on_error as these provide the maximum type-checking, but I guess it will be up to the user what they want to use, as far as they know and understand the trade-offs

iMacTia commented 8 months ago

Another way to provide a signature for the procs without using HOFs, is to use T.let:

sig { returns(String) }
def test_return_type_check
  res = do_something(true).either(
    T.let(
      ->(payload) { "Payload was #{payload}" },
      T.proc.params(arg0: Integer).returns(String)
    ),
    T.let(
      ->(error) { raise error },
      T.proc.params(arg0: String).returns(String)
    ),
  )

  T.assert_type!(res, String)
end
maxveldink commented 8 months ago

Yeah so I spent some time looking into this, and the issue is that when you define a proc using proc { ... } or -> { ... }, sorbet treats them as params(T.untyped).returns(T.untyped).

Aha, yea I didn't realize that's what Sorbet was doing but now that makes more sense.

Yea, I agree and_then and on_error is probably the path we should encourage. I'm hesitant to merge this in with a large caveat of needing T.let or HoF to communicate the typing to Sorbet; that doesn't seem ergonomic enough to be worth it.

I'm going to leave this open to see if anyone else has any thoughts, but will probably opt to close it in a few days.

maxveldink commented 8 months ago

I'm closing this PR for now for the aforementioned reasons. I don't think we should support this method right now with the current runtime generic erasures.