dbrattli / Expression

Pragmatic functional programming for Python inspired by F#
https://expression.readthedocs.io
MIT License
424 stars 31 forks source link

@effect.result decorator return type #47

Closed bgrounds closed 2 years ago

bgrounds commented 2 years ago

I've started plumbing the Try type throughout a program I'm working on for better error handling :)

I just realized that the @result decorator drops type information (by returning a ResultBuilder[Any, Any]).

I'm not sure if a decorator function could be written such that the type checker will infer the resulting (decorated) function's return type based on the input function's return type, which would be ideal. It seems like that should be possible, since the function-to-be-decorated (and its type annotation) will be available to the decorator function, but I haven't tried it.

But even if that's not possible, I'd rather pass type information around explicitly than drop it (via Any).

By defining this try_of helper, I can (with a bit of boilerplate) recover the missing type information.

@effect.result
def surprise() -> Try[List[str]]:
    yield from Success(["Good", "luck", "!"])

def try_of(_: Type[T]) -> ResultBuilder[T, Exception]:
    return effect.result

@try_of(List[str])
def list_of_str() -> Try[List[str]]:
    yield from Success(["well", "typed", ":)"])

# not sure if I love it, but this seems to work, too
@try_of(List[str])
def list_of_str_without_explicit_return_type_annotation():
    yield from Success(["still", "well", "typed", ":)"])

@effect.result
def caller():
    yield from (
        a + b + c
        for a in list_of_str()  # pyright infers a: List[str]
        for b in surprise()  # pyright infers b: Any
        for c in list_of_str_without_explicit_return_type_annotation()  # pyright infers c: List[str]
    )

Any thoughts? Is there an easier way to do this already?

ssjw commented 2 years ago

You may want to try what I did for for the @effect.option decorator. Instead of using @effect.option which is typed as Any, I imported the OptionBuilder class and gave it the return type of the function it was decorating. So, using the example in the tutorial:

from expression import Nothing, Some 
from expression.core.option import default_value
from expression.effect.option import OptionBuilder

@OptionBuilder[int]()
def fn():
    x = yield from Nothing # or a function returning Nothing
    # x = yield from Some(42) # or a function returning Nothing
    print(x)

    # -- The rest of the function will never be executed --
    y = yield from Some(43)
    print(y)

    return x + y

xs = fn()
x = default_value(0)(xs)
y = default_value(0)(Some(43))
# assert xs is Nothing
z = x + y
print(f'at the end, z is {z}')

The extra variables and print statements were so I could understand the program flow and view the type information Pylance reported for the expressions.

Maybe using ResultBuilder directly would also work as you need and keep the type information.

Note: I had to change the type returned by the Nothing.__iter__() function in the expression.core.option.py file in the expression library to:

    def __iter__(self) -> Generator[TSource, TSource, TSource]:

to get Pylance to type yield from Nothing correctly. I am going to report that... hopefully someone is taking care of this library... it looks like it took considerable work to create it.

dbrattli commented 2 years ago

I have made a PR #59 to have better typing of effects. This is a breaking change and you will need to call the decorator to set the type you need e.g:

  @effect.result[int, Exception]()
  def fn():
      x: int = yield 42
      y = yield from Ok(43)

      return x + y

What do you think of this solution? Would this fix your problem?

I btw also added _try so you can write:

  @effect.try_[int]()
  def fn():
      x: int = yield 42
      y = yield from Ok(43)

      return x + y

NOTE: for this to work we need to drop support for Python 3.8