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.
Note: Typically, you want to work with functions that are generous in which type of arguments they accept, while they’re specific about the type of their return value. For example, a function may accept any iterable like a list, tuple, or generator, but always return a list.
If your function can return several different types, then you should first consider whether you can refactor it to have a single return type. In this tutorial, you’ll learn how to deal with those functions that need multiple return types.
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.
Get Your Code: Click here to get access to the free sample code that shows you how to declare type hints for multiple types in Python.
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:
-
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.
-
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.
-
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.
-
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:
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.
Note: In practice, the validation rules for email addresses are much more complicated.
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:
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.
Note: One challenge with functions that may return different types is that you need to check the return type when you call the function. In the examples above, you need to test whether you got None
when parsing the email address.
If the return type can be deduced from the argument types, then you can alternatively use @overload
to specify different type signatures.
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:
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:
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.
Note: As with alternative types in a union, you can have an arbitrary number of elements and types in a tuple to combine multiple pieces of data in a type hint. Here’s an example:
def get_user_info(user: User) -> tuple[str, int, bool]:
...
In this case, the function returns three values. One is a string, the next is an integer, and the third is a Boolean value.
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.
Note: Don’t confuse collections.abc.Callable
with typing.Callable
, which has been deprecated since Python 3.9.
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:
>>> 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:
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.
Note: As written, there’s no explicit relationship between the ellipsis inside Callable
and the Any
annotations of *args
and **kwargs
. You can use parameter specification variables to improve these type hints further:
from collections.abc import Callable
from typing import ParamSpec, TypeVar
P = ParamSpec("P")
T = TypeVar("T")
def apply_func(func: Callable[P, T], *args: P.args, **kwargs: P.kwargs) -> T:
return func(*args, **kwargs)
Now, P
represents all the parameters of func
, and apply_func()
will inherit the same types for its *args
and **kwargs
.
If you’re on a Python version before Python 3.10, then you need to import ParamSpec
from typing_extensions
instead.
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:
>>> 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:
>>> @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:
>>> 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.
Note: As with the Callable
type, don’t confuse collections.abc.Generator
with the deprecated typing.Generator
type.
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:
>>> 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:
- 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.
- 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.
- 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:
>>> 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:
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:
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.
Note: While you can reserve the freedom to change the return type later, as in the example above, in most cases you should strive to be as specific in your return type annotations as possible.
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:
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:
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
:
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.
Note: There are subtle differences in using type
and TypeAlias
, which aren’t drop-in replacements for each other. To learn more about type
and other new typing features in Python 3.12, check out Python 3.12 Preview: Static Typing Improvements.
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
:
(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:
# 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:
(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:
# 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:
(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:
# 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:
(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 theUnion
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
, andIterable
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.
Get Your Code: Click here to get access to the free sample code that shows you how to declare type hints for multiple types in Python.