Idea: `ReturnType` to refer to function's return type

Noticed a pattern of repeating a type annotation in functions returning a bit verbose generic container and having a way to duplicate the return type would be helpful.

Would it make sense for this case to have something like ReturnType as a shortcut to refer from function body to it’s return type? Any other similar cases where this could be useful?

Haven’t found this proposed. There was an idea of ReturnType for mypy (Add ReturnType-like utility for getting function return types · Issue #8385 · python/mypy · GitHub) but it was about a tool to introspect other functions return type. Maybe those ideas can be combined and ReturnType without any arguments would refer to return type from the current scope.

# Currently:
def foo() -> dict[str, tuple[float, float, float]]:
    lst: dict[str, tuple[float, float, float]] = {}
    lst["5"] = (1, 2, 3) # OK
    lst[2] = (4, 5, 6) # Error
    return lst

# With ReturnType:
from typing import ReturnType

def foo() -> dict[str, tuple[float, float, float]]:
    lst: ReturnType = {}
    lst["5"] = (1, 2, 3) # OK
    lst[2] = (4, 5, 6) # Error
    return lst
5 Likes

Even though it has been some time, I would like to ask how that would help? Why not make a type alias to do the same thing?

from typing import TypeAlias


R: TypeAlias = dict[str, tuple[float, float, float]]


def foo() -> R:
   lst: R = {}
   lst["5"] = (1, 2, 3) # OK
   lst[2] = (4, 5, 6) # Error
   return lst

# OR:

type R = dict[str, tuple[float, float, float]]


def foo() -> R:
   lst: R = {}
   lst["5"] = (1, 2, 3) # OK
   lst[2] = (4, 5, 6) # Error
   return lst

Note that I’m just replying in case you might still wonder how to do that. Obviously a Return type that just somehow fetches the current scopes __annotations__ dict and returns it’s annot for return would work, but this is he intended solution.

I could see this being helpful.

Take this example, assuming that ReturnType refers to the return type of the enclosing function or method, this should type check just fine.

from typing import List, Union

def process_numbers(nums: List[int]) -> List[int]:
    result_list: ReturnType = []

    for num in nums:
        result_list.append(num * num)
    return result_list

Then say I decided to change my mind and this function should return the numbers as strings.

from typing import List, Union

def process_numbers(nums: List[int]) -> List[str]: # Notice I changed the inner type to str.
    result_list: ReturnType = []

    for num in nums:
        result_list.append(num * num) # This should be a type error now.
    return result_list

This should be semantically equivalent to something like this:

from typing import List, Union

type ProcessNumbersReturnType = List[str]

def process_numbers(nums: List[int]) -> ProcessNumbersReturnType:
    result_list: ProcessNumbersReturnType = []

    for num in nums:
        result_list.append(num * num)
    return result_list

Although my example is simple enough that type inference will probably get it right anyway. But I’ve encountered a few cases where a ReturnType alias could have saved me a few strokes of typing.

Imo, it’s too complicated to get the ReturnType to execute code, as it does not get called (like ReturnType() would be), although if some function were called, we could get the specific frame, get the scope, get fn.__attributes__ and return the specified attribute.

I could however imagine some generic typing.ReturnType, where the __class_getitem__ will do the job. Therefore the code should look like following, if we used typing.ReturnType:

from typing import ReturnType

def chance_it_will_rain() -> int:
     """
     Returns the chance for it to rain as an integer
     """
     chance: ReturnType[chance_it_will_rain] = fetch_chance_of_rain()
     return chance

# OR (After changing the type)

def chance_it_will_rain() -> str:
     """
     Returns the chance for it to rain as a string
     """
     chance: ReturnType[chance_it_will_rain] = fetch_chance_of_rain()
     return chance

Maybe then however, especially when using another function, we could add some other typing.Copy, which copies param x’s signature. That means some Copy[fetch_chance_of_rain, "<param name>"] would get the function fetch_chance_of_rains __annotations__[param] (and maybe get it differently for stubs, e.g.: Copy[len, "return"] would return int even though the function was designed as a stub.).

The special cases for Copy[fn, "*args"] and Copy[fn, "**kwargs"] should also work, with first returning int and latter returning bool for some function fn(*args: int, **kwargs: bool) -> str: .... That system could then have a way to detect * and ** patterns and if they are not present, but names match, we would return tuple[T, ...] or dict[str, T] respectively. A short way to write the ReturnType would then be:

from typing import Copy, TypeVar

Fn = TypeVar("Fn") # bound should probably be set to some Callable / Union of Callables
ReturnType[Fn] = Copy[Fn, "return"]

...

Other aliases could then include Args[Fn] and Kwargs[Fn] too, being some special case that can be unpacked by typing.Unpack to get T for some actual tuple[T, ...] or dict[str, T].

What will happen if the function is decorated with a decorator that changes the return type? For example, consider this very realistic and overwhelmingly useful piece of code:

@contextlib.contextmanager
def context_thingy() -> Generator[None]:
    not_really_a_good_example: ReturnType
    reveal_type(not_really_a_good_example)  # generator or contextmanager?

    yield 

I see ReturnType as syntactic sugar where your example would be equivalent to:

import contextlib
from collections.abc import Generator

type ContextThingyReturnType = Generator[None]

@contextlib.contextmanager
def context_thingy() -> ContextThingyReturnType:
    not_really_a_good_example: ContextThingyReturnType
    reveal_type(not_really_a_good_example)
    yield

In this case, mypy reveals it to be a Generator.

I think it’s inevitable that some day there will be a feature to type-introspect other functions (e.g. ReturnType[some_func]) that would get function return type after it’s wrapped (since it’s intuitive that ReturnType[some_func] should match reveal_type(some_func)).

So ReturnType used just by itself in a local context to get a function return type should also match this behaviour and return not very useful _GeneratorContextManager in this case.

And for this kind of cases there could be a separate thing like ReturnTypeLocal or ReturnTypeUnwrapped (can also be used as ReturnTypeUnwrapped[function_to_introspect]).

Though no idea how often this kind of cases come up, it seems most of the time return type before and after the wrapping is the same.

import contextlib
from collections.abc import Generator


@contextlib.contextmanager
def context_thingy() -> Generator[None]:
    not_really_a_good_example: ReturnType
    # _GeneratorContextManager[None, None, None]
    reveal_type(not_really_a_good_example)

    not_really_a_good_example_: ReturnTypeLocal
    # Generator[None]
    reveal_type(not_really_a_good_example_)

    # () -> _GeneratorContextManager[None, None, None]
    reveal_type(context_thingy)

    yield

For what it’s worth, I would expect ReturnType in this context to give me the pre-decorator return type (Generator[None], not _GeneratorContextManager). It should look at the function you’re syntactically in, not at what happens to the function after it’s defined.

1 Like

But doesn’t it contradict with x being B, not A in the example below?
That’s the main thing that led me into thinking that using “final” return type instead of the local one is more logical.

class A: ...
class B: ...

def wrapper(f):
    def f_() -> B: ...
    return f_

@wrapper
def test() -> A:
    x = test()
    reveal_type(x) # B

    # Cannot assign type B to type A
    # if ReturnType would prefer local context.
    y: ReturnType = test()

Yes, but how would typing.ReturnType know it has been changed to a Generator or similar?

from typing import ReturnType


@make_fn_return_bool_wrapper
def foo() -> int:
    val: ReturnType[foo] = ...
    return val

Now here, foo is annotated as a function returning int. But the decorators annotations should overwrite that?

I pretty much completely agree with what Jelle said earlier in the thread.

Well the, in my Opinion, if we get some typing.ReturnType, it should unwrap it, if we pass it like ReturnType[ReturnType[func]] for some:

from typing import Callable, Any
from typing import ReturnType


# I'm too lazy to use ParamSpecs here, sorry
def wrapper(func: Callable[..., Any]) -> Callable[..., bool]:
     ...

@wrapper
def func() -> str: 
    return "hi"

ReturnType[func] # bool
ReturnType[ReturnType[func]] # str

There we could now get a wrapped and unwrapped version. I’m thinking about proposing to add * / typing.Unpack as syntaxes for ReturnType[ReturnType[...]] too, although I’m not too sure About that, as unpack has a different purpose imo.

Using ReturnType with other functions seems like a cool idea to me.

As for using it to refer to the current function’s return type, wouldn’t we be better off mandating a little bit of inference instead?

def foo() -> dict[str, tuple[float, float, float]]:
    # The type checker sees this is returned so it can infer it's a dict[str, tuple[float, float, float]]
    lst = {}  
    lst["5"] = (1, 2, 3) # OK
    lst[2] = (4, 5, 6) # Error
    return lst

Since users are generally encouraged to use as specific types as possible in return annotations this could be genuinely useful.

1 Like