DEV Community

Cover image for Python Type Hints: Callable Objects, Iterables and More
Sachin
Sachin

Posted on • Originally published at geekpython.in

Python Type Hints: Callable Objects, Iterables and More

Python is a dynamically typed language, meaning you do not need to specify the type of variables, parameters or return values. This is determined during program execution based on the values assigned to the variable or passed as the argument.

Python introduced type hints or static typing with version 3.5, allowing developers to declare the data type of variables, parameters, etc.

What is Type Hint?

x: int = 5
y: str = "5"
Enter fullscreen mode Exit fullscreen mode

In this example, the variable x expects an integer value while the variable y expects a string value.

This is called type hint or static typing where you specify the expected data type for a variable, parameter or return value of a function.

Python has a different way of declaring type hints for the variables, return values, collections, etc.

Consider the following example:

def circle(radius: float) -> str:
    area = 3.14 * radius ** 2
    return f"Area of circle: {area}"
Enter fullscreen mode Exit fullscreen mode

In this example, the function circle accepts an argument radius which is expected to be a float value as indicated by type hint radius: float, and the return value of this function is expected to be a string, as indicated by the -> str hint.

Performing a Check

What if you pass the argument of a different type which is not expected? Consider the function below:

def date(dd: str, mm: str, yyyy: str) -> str:
    return f"Current Date: {dd}-{mm}-{yyyy}"

curr_date = date(22, 9, 2024)  # expected 'str' got 'int'
print(curr_date)
Enter fullscreen mode Exit fullscreen mode

The function date() accepts three arguments and expects all of them to be a string value, however, integer values are supplied when the function is called.

What do you think? Will this program throw an error? Well, it looks like but Python or specifically interpreter completely ignores the type hints as this is not its purpose.

Current Date: 22-9-2024
Enter fullscreen mode Exit fullscreen mode

The type of arguments was decided in the runtime that is why no error is thrown, however, you may see a warning in your IDE or code editor.

Annotating Multiple Return Types

In this section, you'll learn how to annotate multiple return types for a single value of alternative types and multiple values of different types.

Alternative Types for a Return Value

def cart(item: str) -> str | None:
    if item == "":
        return None
    return "Item added in the cart"
Enter fullscreen mode Exit fullscreen mode

The function cart() accepts an argument item and returns None if it is not supplied, otherwise, it returns a string.

To represent multiple return types for a function, the (|) pipe can be used. It means either str or None. So, when you call the function, it indicates that the return value of the function can be a str or None.

You can also use typing.Union to accomplish the same task you've done using the (|).

from typing import Union

def cart(item: str) -> Union[None, str]:
    if item == "":
        return None
    return "Item added in the cart"
Enter fullscreen mode Exit fullscreen mode

The Union[None, str] is equivalent to None | str. None | str is a shorthand and it is a recommended way.

Multiple Return Values of Different Types

def cart(item: str, quantity: int) -> dict[str, int] | None:
    if item == "" or quantity == 0:
        return None
    return {item: quantity}
Enter fullscreen mode Exit fullscreen mode

The function cart() has been updated to accept an additional argument quantity and returns None if any of the arguments are left empty, otherwise, it returns a dictionary containing a key (str) and value (int).

If you look at the type hint for the return value, this time different types (str and int) are expected to return in a dictionary (dict).

You can do this in the following way.

from typing import Union, Dict

def cart(item: str, quantity: int) -> Union[Dict[str, int], None]:
    if item == "" or quantity == 0:
        return None
    return {item: quantity}
Enter fullscreen mode Exit fullscreen mode

You can use Mapping instead of Dict to represent the dictionary.

from typing import Union, Mapping

def cart(item: str, quantity: int) -> Union[Mapping[str, int], None]:
    if item == "" or quantity == 0:
        return None
    return {item: quantity}
Enter fullscreen mode Exit fullscreen mode

But all this seems a bit verbose so you can go for shorthand syntax.

Type Hinting Functions

from collections.abc import Callable

def apply_cart(
        func: Callable[[str, int], dict[str, int]],
        item: str,
        quantity: int
) -> dict[str, int]:
    return func(item, quantity)

def cart(item: str, quantity: int) -> dict[str, int] | None:
    if item == "" or quantity == 0:
        return None
    return {item: quantity}
Enter fullscreen mode Exit fullscreen mode

The function apply_cart() accepts a callable object (which can be a function or any other callable object) and two arguments (item and quantity) and returns a dictionary containing the key and value.

The Callable type hint provides a list of arguments ([str, int]) that the callable object accepts. In this case, func() expects strings and integers. Callable's second parameter is the return value (dict[str, int]), which is a dictionary.

The second function, cart(), is identical to the previous example and returns a dictionary if everything is correct.

cart_item = apply_cart(cart, "Mouse", 2)
print(cart_item)
--------------------
{'Mouse': 2}
Enter fullscreen mode Exit fullscreen mode

The apply_cart() function is invoked with the cart (a function) and two arguments ('Mouse', 2).

The apply_cart() function calls the cart() function with the given arguments and returns a result.

Here's an optimisation, if you have many arguments of different types, you can use ellipsis (...) rather than passing different input types.

The ellipsis literal (...) indicates that callable can accept any arbitrary list of arguments.

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

T = TypeVar("T")

def apply_cart(
        func: Callable[..., T],
        *args: Any
) -> T:
    return func(*args)
Enter fullscreen mode Exit fullscreen mode

The apply_cart() function has been updated and now accepts an arbitrary list of arguments of any type ([..., T]) as well as variadic argument (*args) of any type (Any).

The first parameter in the Callable is an ellipsis (...), suggesting that any arbitrary parameter list is permitted.

The second parameter is a type variable (T = TypeVar("T")) that can work with any type. It indicates that the callable can accept any type and return an element of that type.

You can also use a parameter specification variable (ParamSpec) instead of an ellipsis to make callable objects accept any number of positional or keyword arguments.

from collections.abc import Callable
from typing import TypeVar, ParamSpec

P = ParamSpec("P")
T = TypeVar("T")

def apply_cart(
        func: Callable[P, T],
        *args: P.args
) -> T:
    return func(*args)
Enter fullscreen mode Exit fullscreen mode

In this case, the first parameter of Callable is a parameter specification variable (P = ParamSpec("P")) indicating arbitrary list of arguments is acceptable. The second parameter (T) indicates that any type is acceptable.

The function apply_cart() also accepts a variadic argument (*args) of type P.args that represents a tuple of any number and type of positional arguments. To annotate **kwargs, P.kwargs must be used that represents the mapping of keyword parameters to their values.

Instead of using the ellipsis literal, you may now utilise ParamSpec and TypeVar to enable callable objects to accept any number of positional or keyword arguments of any type.

The code is further updated by adding **kwargs, which allows the function to accept keyword arguments of arbitrary length.

from collections.abc import Callable
from typing import TypeVar, ParamSpec

P = ParamSpec("P")
T = TypeVar("T")

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


def cart(item: str, quantity: int) -> dict[str, int] | None:
    if item == "" or quantity == 0:
        return None
    return {item: quantity}

cart_item = apply_cart(cart, "Mouse", quantity=2)
print(cart_item)
Enter fullscreen mode Exit fullscreen mode

Type Hinting Iterables

Consider the following function that takes a list of names and sorts them in ascending order.

def sort_student(names: list[str]) -> None:
    sorting = sorted(names)
    for name in sorting:
        print(name)

n = ["Gojo", "Yuta", "Yuji", "Megumi"]
sort_student(n)
Enter fullscreen mode Exit fullscreen mode

The list[str] type is used to type hint the parameter names to indicate that it expects a list of strings.

But what if you want names to be a type tuple or set instead? You need to refactor your code. In this case, you need to change the type in one location, however, this might be hard in complex and big projects.

You can use the type Iterable to make the function accept any iterable object.

from collections.abc import Iterable

def sort_student(names: Iterable[str]) -> None:
    sorting = sorted(names)
    for name in sorting:
        print(name)

n1 = ("Gojo", "Yuta", "Yuji", "Megumi") # No error
n2 = ["Gojo", "Yuta", "Yuji", "Megumi"] # No error
n3 = {"Gojo", "Yuta", "Yuji", "Megumi"} # No error
Enter fullscreen mode Exit fullscreen mode

Type Aliases for Better Readability

The complex type hints for callable objects like in the above case, arguments, and return values of the functions kind of feel cumbersome to write.

Why can't simplify the complex type by giving them an alias(name)? You can create an alias for the type in the same way you would create a variable.

type ReturnCart = dict[str, int] | None

def cart(item: str, quantity: int) -> ReturnCart:
    if item == "" or quantity == 0:
        return None
    return {item: quantity}

cart_item = apply_cart(cart, "Mouse", quantity=2)
print(cart_item)
Enter fullscreen mode Exit fullscreen mode

In the above example, an alias ReturnCart is created and contains the type of return value (dict[str, int] | None).

The alias is defined with the soft keywordtype, which creates an instance of TypeAliasType. This keyword was added in Python 3.12. If you are using an older version of Python, you can choose an alternate method.

Type aliases can also be created like you would create a variable using a simple assignment.

ReturnCart = dict[str, int] | None
Enter fullscreen mode Exit fullscreen mode

To clarify, you can mark it with TypeAlias to explicitly show this as a type alias, not a simple variable.

from typing import TypeAlias

ReturnCart: TypeAlias = dict[str, int] | None
Enter fullscreen mode Exit fullscreen mode

The methods described above will assist you in simplifying complex types, and the key benefit is that you will only need to edit the type in one spot, eliminating the need for refactoring.

Another advantage of using type aliases is that they can be reused in other portions of your code, which improves code readability.

Type Checker Tools

Python entirely ignores type hints and determines the type of variables, function arguments, and return values at runtime.

Mypy, a popular third-party tool, can enforce type-checking in Python. Since it is a third-party tool, you must install it using the following command.

python -m pip install mypy
Enter fullscreen mode Exit fullscreen mode

This will provide you access to the mypy command to type-check your program.

Static Type Checking Using Mypy

The function sort_student() takes an iterable object and sorts the student names in ascending order. Save this function to a file, such as student.py.

# student.py
from collections.abc import Iterable

def sort_student(names: Iterable[str]) -> None:
    sorting = sorted(names)
    for name in sorting:
        print(name)

n1 = ("Gojo", "Yuta", "Yuji", "Megumi")
sort_student(n1)
Enter fullscreen mode Exit fullscreen mode

To type check your program using mypy, enter the command mypy followed by the file name you want to check in the terminal.

> mypy student.py 
Success: no issues found in 1 source file
Enter fullscreen mode Exit fullscreen mode

The type checker identified no errors. One thing you may have noticed is that the code contains the function call (sort_student(n1)), which should have created the output, but there was none in the terminal except the message created by mypy.

This means that mypy does not execute your code, instead, it examines if the values match the expected type based on the type hints.

What if you made a mistake and the actual values do not match the expected type?

# student.py
def sort_student(names: list[str]) -> None:
    sorting = sorted(names)
    for name in sorting:
        print(name)

n1 = ("Gojo", "Yuta", "Yuji", "Megumi")
sort_student(n1)
Enter fullscreen mode Exit fullscreen mode

This function expects a list of strings but the tuple was passed. If you now check your code, it will generate the following error.

> mypy student.py
student.py:8: error: Argument 1 to "sort_student" has incompatible type "tuple[str, str, str, str]"; expected "list[str]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
Enter fullscreen mode Exit fullscreen mode

Mypy has identified the error on line number 8 saying that the argument passed to sort_student() has an incompatible type tuple[str, str, str, str], it is expected to be a list[str].

Resources

Conclusion

Type hints are optional in Python but they can be used to make the code more readable and using type hints can avoid possible bugs in your code.

In this article, you've learned:

  • Implementing type hints

  • Alternative types for single data and different types for multiple values using pipe (|) and Union

  • Callable type for type hinting functions

  • Ellipsis (...) and TypeVar in Callable type to make a callable object accept an arbitrary list of arguments of any type

  • ParamSpec instead of an ellipsis with TypeVar in Callable type to make a good combination for a callable object accepting an arbitrary list of arguments of any type

  • Iterable type for type hinting iterable objects

  • Type aliases to simplify complex type

  • Mypy for type checking in Python

There is much more you can do with type hints in Python.


πŸ†Other articles you might be interested in if you liked this one

βœ…Best Practices: Positional and Keyword Arguments in Python

βœ…Decorators in Python and How to Create a Custom Decorator?

βœ…Create a WebSocket Server and Client in Python.

βœ…Create and Interact with MySQL Database in Python

βœ…Understanding the Different Uses of the Asterisk(*) in Python?

βœ…Yield Keyword in Python with Examples?


That's all for now

Keep Coding✌✌

Top comments (1)

Collapse
 
msc2020 profile image
msc2020

Thanks for sharing! This incremental approach to explaining the subject was very good.