How to Use Type Hints for Multiple Return Types in Python

How to Use Type Hints for Multiple Return Types in Python

by Claudia Ng Oct 30, 2023 intermediate

In Python, type hinting is an optional yet useful feature to make your code easier to read, reason about, and debug. With type hints, you let other developers know the expected data types for variables, function arguments, and return values. As you write code for applications that require greater flexibility, you may need to specify multiple return types to make your code more robust and adaptable to different situations.

You’ll encounter different use cases where you may want to annotate multiple return types within a single function in Python. In other words, the data returned can vary in type. In this tutorial, you’ll walk through examples of how to specify multiple return types for a function that parses a string from an email address to grab the domain name.

In addition, you’ll see examples of how to specify type hints for callback functions or functions that take another function as input. With these examples, you’ll be ready to express type hints in functional programming.

To get the most out of this tutorial, you should know the basics of what type hints in Python are and how you use them.

Use Python’s Type Hints for One Piece of Data of Alternative Types

In this section, you’ll learn how to write type hints for functions that can return one piece of data that could be of different types. The scenarios for considering multiple return types include:

  1. Conditional statements: When a function uses conditional statements that return different types of results, you can convey this by specifying alternative return types for your function using type hints.

  2. Optional values: A function may sometimes return no value, in which case you can use type hints to signal the occasional absence of a return value.

  3. Error handling: When a function encounters an error, you may want to return a specific error object that’s different from the normal results’ return type. Doing so could help other developers handle errors in the code.

  4. Flexibility: When designing and writing code, you generally want it to be versatile, flexible, and reusable. This could mean writing functions that can handle a range of data types. Specifying this in type hints helps other developers understand your code’s versatility and its intended uses in different cases.

In the example below, you use type hints in working with conditional statements. Imagine that you’re processing customer data and want to write a function to parse users’ email addresses to extract their usernames.

To represent one piece of data of multiple types using type hints in Python 3.10 or newer, you can use the pipe operator (|). Here’s how you’d use type hints in a function that typically returns a string containing the username but can also return None if the corresponding email address is incomplete:

Python
def parse_email(email_address: str) -> str | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username
    return None

In the example above, the parse_email() function has a conditional statement that checks if the email address passed as an argument contains the at sign (@). If it does, then the function splits on that symbol to extract the elements before and after the at sign, stores them in local variables, and returns the username. If the argument doesn’t contain an at sign, then the return value is None, indicating an invalid email address.

So, the return value of this function is either a string containing the username or None if the email address is incomplete. The type hint for the return value uses the pipe operator (|) to indicate alternative types of the single value that the function returns. To define the same function in Python versions older than 3.10, you can use an alternative syntax:

Python
from typing import Union

def parse_email(email_address: str) -> Union[str, None]:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username
    return None

This function uses the Union type from the typing module to indicate that parse_email() returns either a string or None, depending on the input value. Whether you use the old or new syntax, a union type hint can combine more than two data types.

Even when using a modern Python release, you may still prefer the Union type over the pipe operator if your code needs to run in older Python versions.

Now that you know how to define a function that returns a single value of potentially different types, you can turn your attention toward using type hints to declare that a function can return more than one piece of data.

Use Python’s Type Hints for Multiple Pieces of Data of Different Types

Sometimes, a function returns more than one value, and you can communicate this in Python using type hints. You can use a tuple to indicate the types of the individual pieces of data that a function returns at once. In Python 3.9 and later, you can use built-in tuple data structure. On older versions, you need to use typing.Tuple in your annotations.

Now, consider a scenario where you want to build on the previous example. You aim to declare a function whose return value incorporates multiple pieces of data of various types. In addition to returning the username obtained from an email address, you want to update the function to return the domain as well.

Here’s how you’d use type hints to indicate that the function returns a tuple with a string for the username and another string for the domain:

Python
def parse_email(email_address: str) -> tuple[str, str] | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

The function’s return type above is a pair of two strings that correspond to the username and domain of an email address. Alternatively, if the input value doesn’t constitute a valid email address, then the function returns None.

The type hint for the return value contains a tuple with two comma-separated str elements inside square brackets, which tells you that the tuple has exactly two elements, and they’re both strings. Then, a pipe operator (|) followed by None indicates that the return value could be either a two-string tuple or None, depending on the input value.

To implement the same function in Python earlier than 3.10, use the Tuple and Union types from the typing module:

Python
from typing import Tuple, Union

def parse_email(email_address: str) -> Union[Tuple[str, str], None]:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

This notation is slightly more verbose and requires importing two additional types from the typing module. On the other hand, you can use it in older Python versions.

Okay, it’s time for you to move on to more advanced type hints in Python!

Declare a Function to Take a Callback

In some programming languages, including Python, functions can return other functions or take them as arguments. Such functions, commonly known as higher-order functions, are a powerful tool in functional programming. To annotate callable objects with type hints, you can use the Callable type from the collections.abc module.

A common type of higher-order function is one that takes a callback as an argument. Many built-in functions in Python, including sorted(), map(), and filter(), accept a callback function and repeatedly apply it to a sequence of elements. Such higher-order functions eliminate the need for writing explicit loops, so they align with a functional programming style.

Here’s a custom function that takes a callable as an argument, illustrating how you’d annotate it with type hints:

Python
>>> from collections.abc import Callable

>>> def apply_func(
...     func: Callable[[str], tuple[str, str]], value: str
... ) -> tuple[str, str]:
...     return func(value)
...
>>> def parse_email(email_address: str) -> tuple[str, str]:
...     if "@" in email_address:
...         username, domain = email_address.split("@")
...         return username, domain
...     return "", ""
...
>>> apply_func(parse_email, "claudia@realpython.com")
('claudia', 'realpython.com')

The first function above, apply_func(), takes a callable object as the first argument and a string value as the second argument. The callable object could be a regular function, a lambda expression, or a custom class with a special .__call__() method. Other than that, this function returns a pair of strings.

The Callable type hint above has two parameters defined inside square brackets. The first parameter is a list of arguments that the input function takes. In this case, func() expects only one argument of type string. The second parameter of the Callable type hint is the return type, which, in this case, is a tuple of two strings.

The next function in the code snippet above, parse_email(), is an adapted version of the function that you’ve seen before that always returns a tuple of strings.

Then, you call apply_func() with a reference to parse_email() as the first argument and the string "claudia@realpython.com" as the second argument. In turn, apply_func() invokes your supplied function with the given argument and passes the return value back to you.

Now, what if you want apply_func() to be able to take different functions with multiple input types as arguments and have multiple return types? In this case, you can modify the parameters inside the Callable type hint to make it more generic.

Instead of listing the individual argument types of the input function, you can use an ellipsis literal (...) to indicate that the callable can take an arbitrary list of arguments. You can also use the Any type from the typing module to specify that any return type is acceptable for the callable.

Even better, you can use type variables to specify the connection between the return type of the callable and of apply_func().

Either option would apply type hinting to the return type only for the function in question. Below, you update the previous example in this manner:

Python
from collections.abc import Callable
from typing import Any, TypeVar

T = TypeVar("T")

def apply_func(func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
    return func(*args, **kwargs)

Notice that you’ve now annotated the Callable above with an ellipsis literal as the first element in square brackets. Therefore, the input function can take any number of arguments of arbitrary types.

The second parameter of the Callable type hint is now T. This is a type variable that can stand in for any type. Since you use T as the return type for apply_func() as well, this declares that apply_func() returns the same type as func.

Because the callable supplied to apply_func() can take arguments of an arbitrary number or no arguments at all, you can use *args and **kwargs to indicate this.

In addition to annotating callable arguments, you can also use type hints to formally specify a callable return type of a function, which you’ll take a closer look at now.

Annotate the Return Value of a Factory Function

A factory function is a higher-order function that produces a new function from scratch. The factory’s parameters determine this new function’s behavior. In particular, a function that takes a callable and also returns one is called a decorator in Python.

Continuing with the previous examples, what if you wanted to write a decorator to time the execution of other functions in your code? Here’s how you’d measure how long your parse_email() function takes to finish:

Python
>>> import functools
>>> import time
>>> from collections.abc import Callable
>>> from typing import ParamSpec, TypeVar

>>> P = ParamSpec("P")
>>> T = TypeVar("T")
>>> def timeit(function: Callable[P, T]) -> Callable[P, T]:
...     @functools.wraps(function)
...     def wrapper(*args: P.args, **kwargs: P.kwargs):
...         start = time.perf_counter()
...         result = function(*args, **kwargs)
...         end = time.perf_counter()
...         print(f"{function.__name__}() finished in {end - start:.10f}s")
...         return result
...     return wrapper
...

The timeit() decorator takes a callable with arbitrary inputs and outputs as an argument and returns a callable with the same inputs and outputs. The ParamSpec annotation indicates the arbitrary inputs in the first element of Callable, while the TypeVar indicates the arbitrary outputs in the second element.

The timeit() decorator defines an inner function, wrapper(), that uses a timer function to measure how long it takes to execute the callable given as the argument. This inner function stores the current time in the start variable, executes the decorated function while capturing its return value, and stores the new time in the end variable. It then prints out the calculated duration before returning the value of the decorated function.

After defining timeit(), you can decorate any function with it using the at symbol (@) as syntactic sugar instead of manually calling it as if it were a factory function. For example, you can use @timeit around parse_email() to create a new function with an additional behavior responsible for timing its own execution:

Python
>>> @timeit
... def parse_email(email_address: str) -> tuple[str, str] | None:
...     if "@" in email_address:
...         username, domain = email_address.split("@")
...         return username, domain
...     return None
...

You’ve added new capability to your function in a declarative style without modifying its source code, which is elegant but somewhat goes against the Zen of Python. One could argue that decorators make your code less explicit. At the same time, they can make your code look simpler, improving its readability.

When you call the decorated parse_email() function, it returns the expected values but also prints a message describing how long your original function took to execute:

Python
>>> username, domain = parse_email("claudia@realpython.com")
parse_email() finished in 0.0000042690s

>>> username
'claudia'

>>> domain
'realpython.com'

The duration of your function is negligible, as indicated by the message above. After calling the decorated function, you assign and unpack the returned tuple into variables named username and domain.

Next up, you’ll learn how to annotate the return value of a generator function that yields values one by one when requested instead of all at once.

Annotate the Values Yielded by a Generator

Sometimes, you may want to use a generator to yield pieces of data one at a time instead of storing them all in memory for better efficiency, especially for larger datasets. You can annotate a generator function using type hints in Python. One way to do so is to use the Generator type from the collections.abc module.

Continuing with the previous examples, imagine now that you have a long list of emails to parse. Instead of storing every parsed result in memory and having the function return everything at once, you can use a generator to yield the parsed usernames and domains one at a time.

To do so, you can write the following generator function, which yields this information, and use the Generator type as a type hint for the return type:

Python
>>> from collections.abc import Generator

>>> def parse_email() -> Generator[tuple[str, str] | str, str, str]:
...     sent = yield "", ""
...     while sent != "":
...         if "@" in sent:
...             username, domain = sent.split("@")
...             sent = yield username, domain
...         else:
...             sent = yield "invalid email"
...     return "Done"
...

The parse_email() generator function doesn’t take any arguments, as you’ll send them to the resulting generator object. Notice that the Generator type hint expects three parameters, the last two of which are optional:

  1. Yield type: The first parameter is what the generator yields. In this case, it’s a tuple containing two strings—one for the username and the other for the domain, both parsed from the email address. Alternatively, the generator may yield an error string when the email address is invalid.
  2. Send type: The second parameter describes what you’re sending into the generator. This is also a string, as you’ll be sending email addresses to the generator.
  3. Return type: The third parameter represents what the generator returns when it’s done producing values. In this case, the function returns the string "Done".

This is how you’d use your generator function:

Python
>>> generator = parse_email()
>>> next(generator)
('', '')
>>> generator.send("claudia@realpython.com")
('claudia', 'realpython.com')
>>> generator.send("realpython")
'invalid email'
>>> try:
...     generator.send("")
... except StopIteration as ex:
...     print(ex.value)
...
Done

You start by calling the parse_email() generator function, which returns a new generator object. Then, you advance the generator to the first yield statement by calling the built-in next() function. After that, you can start sending email addresses to the generator to parse. The generator terminates when you send an empty string.

Because generators are also iterators—namely, generator iterators—you can alternatively use the collections.abc.Iterator type instead of Generator as a type hint to convey a similar meaning. However, because you won’t be able to specify the send and return types using a pure Iterator type hint, collections.abc.Iterator will only work as long as your generator yields values alone:

Python
from collections.abc import Iterator

def parse_emails(emails: list[str]) -> Iterator[tuple[str, str]]:
    for email in emails:
        if "@" in email:
            username, domain = email.split("@")
            yield username, domain

This flavor of the parse_email() function takes a list of strings and returns a generator object that iterates over them in a lazy fashion using a for loop. Even though a generator is more specific than an iterator, the latter is still broadly applicable and easier to read, so it’s a valid choice.

Sometimes Python programmers use the even less restrictive and more general collections.abc.Iterable type to annotate such a generator without leaking the implementation details:

Python
from collections.abc import Iterable

def parse_emails(emails: Iterable[str]) -> Iterable[tuple[str, str]]:
    for email in emails:
        if "@" in email:
            username, domain = email.split("@")
            yield username, domain

In this case, you annotate both the function’s argument and its return type with the Iterable type to make the function more versatile. It can now accept any iterable object instead of just a list like before.

Conversely, the function caller doesn’t need to know whether it returns a generator or a sequence of items as long as they can loop over it. This adds tremendous flexibility because you can change the implementation from an eager list container to a lazy generator without breaking the contract with the caller established through type hints. You can do this when you anticipate that the returned data will be large enough to need a generator.

As you’ve seen in this example, there are a few options when it comes to annotating generators with type hints.

Improve Readability With Type Aliases

Now that you’ve seen how to use type hints to specify multiple different types, it’s worth considering best practices for maintainability. The first concept on this topic is around type aliasing.

If you find yourself using the same set of return types across multiple functions, then it can get tedious trying to maintain all of them separately in different places across your codebase. Instead, consider using a type alias. You can assign a set of type hints to an alias and reuse that alias in multiple functions within your code.

The main benefit of doing this is that if you need to modify the specific set of type hints, then you can do so in a single location, and there’s no need to refactor the return types in each function where you use them.

It’s worth noting that even if you don’t intend to reuse the same type hint across multiple functions, using type aliases can improve the readability of your code. In a keynote speech at PyCon US 2022, Łukasz Langa explained how giving your types meaningful names can help with understanding your code better.

You can do so by giving your type hint an aliased name and then using this alias as a type hint. Here’s an example of how to do this for the same function as before:

Python
EmailComponents = tuple[str, str] | None

def parse_email(email_address: str) -> EmailComponents:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

Here, you define a new EmailComponents variable as an alias of the type hint indicating the function’s return value, which can be either None or a tuple containing two strings. Then, you use the EmailComponents alias in your function’s signature.

Python version 3.10 introduced the TypeAlias declaration to make type aliases more explicit and distinct from regular variables. Here’s how you can use it:

Python
from typing import TypeAlias

EmailComponents: TypeAlias = tuple[str, str] | None

You need to import TypeAlias from the typing module before you can use it as a type hint to annotate your EmailComponents alias. After importing it, you can use it as a type hint for type aliases, as demonstrated above.

Note that since Python 3.12, you can specify type aliases using the new soft keyword type. A soft keyword only becomes a keyword when it’s clear from the context. Otherwise, it can mean something else. Remember that type() is also one of the built-in functions in Python. Here’s how you’d use the new soft keyword type:

Python
type EmailComponents = tuple[str, str] | None

Starting in Python 3.12, you can use type to specify type aliases, as you’ve done in the example above. You can specify the type alias name and type hint. The benefit of using type is that it doesn’t require any imports.

Aliasing type hints with descriptive and meaningful names is a straightforward yet elegant trick that can improve your code’s readability and maintainability, so don’t overlook it.

Leverage Tools for Static Type Checking

As a dynamically typed language, Python doesn’t actually enforce type hints at runtime. This means that a function can specify any desired return type, and the program would still run without actually returning a value of that type or raising an exception.

Although Python doesn’t enforce type hints at runtime, you can use third-party tools for type checking, some of which may integrate with your code editor through plugins. They can be helpful for catching type-related errors during the development or testing process.

Mypy is a popular third-party static type checker tool for Python. Other options include pytype, Pyre, and Pyright. They all work by inferring variable types from their values and checking against the corresponding type hints.

To use mypy in your project, start by installing the mypy package in your virtual environment using pip:

Shell
(venv) $ python -m pip install mypy

This will bring the mypy command into your project. What if you tried to run mypy on a Python module containing a function that you’ve seen previously?

If you recall the parse_email() function from before, it takes a string with an email address as a parameter. Other than that, it returns either None or a tuple of two strings containing the username and the domain. Go ahead and save this function in a file named email_parser.py if you haven’t already:

Python
# email_parser.py

def parse_email(email_address: str) -> tuple[str, str] | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

You can run this code through a type checker by typing mypy followed by the name of your Python file in the command line:

Shell
(venv) $ mypy email_parser.py
Success: no issues found in 1 source file

This runs an automated static code analysis without executing your code. Mypy tries to assess whether the actual values will have the expected types according to the type hints declared. In this case, everything seems to be correct.

But what happens if you make a mistake in your code? Say that the declared return value of parse_email() has an incorrect type hint, indicating a string instead of a tuple of two strings:

Python
# email_parser.py

def parse_email(email_address: str) -> str | None:
    if "@" in email_address:
        username, domain = email_address.split("@")
        return username, domain
    return None

The modified parse_email() function above has a discrepancy between the type hint and one of the values that it actually returns. When you rerun mypy in the command line, you’ll see the following error:

Shell
(venv) $ mypy email_parser.py
email_parser.py:6: error: Incompatible return value type
⮑ (got "tuple[str, str]", expected "str | None")  [return-value]
Found 1 error in 1 file (checked 1 source file)

This message indicates that your function returns a tuple with two strings instead of the expected single string value. Such information is invaluable because it can prevent catastrophic bugs from happening at runtime.

In addition to type checking, mypy can infer types for you. When you run your script through mypy, you can pass in any expression or variable to the reveal_type() function without having to import it. It’ll infer the type of the expression and print it out on the standard output.

When annotating functions with type hints, you can call reveal_type() for trickier cases. Here’s an example of how to use this function to determine the actual type of the parse_email() return value:

Python
# email_parser.py

# ...

result = parse_email("claudia@realpython.com")
reveal_type(result)

In the example above, the variable result contains the two components from a parsed email address. You can pass this variable into the reveal_type() function for mypy to infer the type. Here’s how you’d run it in the console:

Shell
(venv) $ mypy email_parser.py
email_parser.py:10: note: Revealed type is
⮑ "Union[tuple[builtins.str, builtins.str], None]"
Success: no issues found in 1 source file

When you run mypy on your Python file, it prints out the inferred type from the script’s reveal_type() function. In this example, mypy correctly infers that the result variable is a tuple containing two strings or an empty value of None.

Remember that IDEs and third-party static type checker tools can catch type-related errors in the development and testing process. These tools infer the type from return values and ensure that functions are returning the expected type.

Be sure to take advantage of this useful function that can reveal the type hints of more complicated return types!

Conclusion

Although type hinting is optional, it’s a useful concept to make your code more readable, user-friendly, and easier to debug. Type hints signal to other developers the desired inputs and return types of your functions, facilitating collaboration.

In this tutorial, you focused on implementing type hints for more complex scenarios and learned best practices for using and maintaing them.

In this tutorial, you’ve learned how to use:

  • The pipe operator (|) or the Union type to specify alternative types of one piece of data returned from a function
  • Tuples to specify distinct types of multiple pieces of data
  • The Callable type for annotating callback functions
  • The Generator, Iterator, and Iterable types for annotating generators
  • Type aliases for type hints to help simplify complex type hints that you reference in multiple places in your code
  • Mypy, a third-party tool for type checking

Now you’re ready to use type hints in a variety of scenarios. How do you use type hints in your code? Share your use cases in the comments below.

🐍 Python Tricks 💌

Get a short & sweet Python Trick delivered to your inbox every couple of days. No spam ever. Unsubscribe any time. Curated by the Real Python team.

Python Tricks Dictionary Merge

About Claudia Ng

Claudia is an avid Pythonista and Real Python contributor. She is a Data Scientist and has worked for several tech startups specializing in the areas of credit and fraud risk modeling.

» More about Claudia

Each tutorial at Real Python is created by a team of developers so that it meets our high quality standards. The team members who worked on this tutorial are:

Master Real-World Python Skills With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

Master Real-World Python Skills
With Unlimited Access to Real Python

Locked learning resources

Join us and get access to thousands of tutorials, hands-on video courses, and a community of expert Pythonistas:

Level Up Your Python Skills »

What Do You Think?

Rate this article:

What’s your #1 takeaway or favorite thing you learned? How are you going to put your newfound skills to use? Leave a comment below and let us know.

Commenting Tips: The most useful comments are those written with the goal of learning from or helping out other students. Get tips for asking good questions and get answers to common questions in our support portal.


Looking for a real-time conversation? Visit the Real Python Community Chat or join the next “Office Hours” Live Q&A Session. Happy Pythoning!

Keep Learning

Related Topics: intermediate