Contents

Type hint in Python

Type hinting is not mandatory, but it can make your code easier to understand and debug by

  1. Improved readability
  2. Better IDE support: IDEs and linters can use type hints to check your code for potential errors before runtime.

While type hints can be simple classes like float or str , they can also be more complex. The typing module provides a vocabulary of more advanced type hints.

Basics

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# This is how you declare the type of a variable
age: int = 1

# You don't need to initialize a variable to annotate it
a: int  # Ok (no value at runtime until assigned)

# Doing so can be useful in conditional branches
child: bool
if age < 18:
    child = True
else:
    child = False
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
x: int = 1
x: float = 1.0
x: bool = True
x: str = "test"
x: bytes = b"test"

# For collections on Python 3.9+, the type of the collection item is in brackets
x: list[int] = [1]
x: set[int] = {6, 7}

# For mappings, we need the types of both keys and values
x: dict[str, float] = {"field": 2.0}  # Python 3.9+

# For tuples of fixed size, we specify the types of all the elements
x: tuple[int, str, float] = (3, "yes", 7.5)  # Python 3.9+

# For tuples of variable size, we use one type and ellipsis
x: tuple[int, ...] = (1, 2, 3)  # Python 3.9+
1
2
3
4
5
6
7
8
# On Python 3.8 and earlier, the name of the collection type is
# capitalized, and the type is imported from the 'typing' module
from typing import List, Set, Dict, Tuple
x: List[int] = [1]
x: Set[int] = {6, 7}
x: Dict[str, float] = {"field": 2.0}
x: Tuple[int, str, float] = (3, "yes", 7.5)
x: Tuple[int, ...] = (1, 2, 3)

Union

Union is for multiple types

1
2
3
4
5
6
7
def process_message(msg: Union[str, bytes, None]) -> str:
	...

# On Python 3.10+, use the | operator when something could be one of a few types
x: list[int | str] = [3, 5, "test", "fun"]  # Python 3.10+
# On earlier versions, use Union
x: list[Union[int, str]] = [3, 5, "test", "fun"]

Optional

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# food can be either str or None.
def eat_food(food: Optional[str]) -> None:
	...

# Use Optional[X] for a value that could be None
# Optional[X] is the same as X | None or Union[X, None]
x: Optional[str] = "something" if some_condition() else None
if x is not None:
    # Mypy understands x won't be None here because of the if-statement
    print(x.upper())
# If you know a value can never be None due to some logic that mypy doesn't
# understand, use an assert
assert x is not None
print(x.upper())

Any

  • Any is a special type hint in Python that indicates that a variable can be of any type. It essentially disables static type checking for that variable.
  • It’s typically used when you want to explicitly indicate that a certain variable can have any type, or when dealing with dynamically typed code where the type of the variable cannot be easily inferred.
  • While Any provides flexibility, it also sacrifices the benefits of static type checking, as type errors related to variables annotated as Any won’t be caught by type checkers.

Functions: Callable Types

Callable type hint can define types for callable functions.

1
2
from typing import Callable
Callable[[Parameter types, ...], return_types] 
  • Callable objects are functions, classes, and so on.
  • Type [input types] and return types
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def on_some_event_happened(callback: Callable[[int, str, str], int]) -> None:
    ...

def do_this(a: int, b: str, c:str) -> int:
    ...

on_some_event_happened(do_this)

# This is how you annotate a callable (function) value
x: Callable[[int, float], float] = f
def register(callback: Callable[[str], int]) -> None: 
	...

# A generator function that yields ints is secretly just a function that
# returns an iterator of ints, so that's how we annotate it
def gen(n: int) -> Iterator[int]:
    i = 0
    while i < n:
        yield i
        i += 1

# You can of course split a function annotation over multiple lines
def send_email(address: Union[str, list[str]],
               sender: str,
               cc: Optional[list[str]],
               bcc: Optional[list[str]],
               subject: str = '',
               body: Optional[list[str]] = None
               ) -> bool:
    ...

Classes

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class BankAccount:
    # The "__init__" method doesn't return anything, so it gets return
    # type "None" just like any other method that doesn't return anything
    def __init__(self, account_name: str, initial_balance: int = 0) -> None:
        # mypy will infer the correct types for these instance variables
        # based on the types of the parameters.
        self.account_name = account_name
        self.balance = initial_balance

    # For instance methods, omit type for "self"
    def deposit(self, amount: int) -> None:
        self.balance += amount

    def withdraw(self, amount: int) -> None:
        self.balance -= amount

# User-defined classes are valid as types in annotations
account: BankAccount = BankAccount("Alice", 400)
def transfer(src: BankAccount, dst: BankAccount, amount: int) -> None:
    src.withdraw(amount)
    dst.deposit(amount)

Annotated

Annotated in python allows developers to declare type of a reference and and also to provide additional information related to it.

1
name = Annotated[str, "first letter is capital"]

This tells that name is of type str and that name[0] is a capital letter.

On its own Annotated does not do anything other than assigning extra information (metadata) to a reference. It is up to another code, which can be a library, framework or your own code, to interpret the metadata and make use of it.

For example FastAPI uses Annotated for data validation:

1
def read_items(q: Annotated[str, Query(max_length=50)])
  • Here the parameter q is of type str with a maximum length of 50.
  • This information was communicated to FastAPI (or any other underlying library) using the Annotated keyword.

Annotated[<type>, <metadata>]

Here is an example of how you might use Annotated to add metadata to type annotations if you were doing range analysis:

1
2
3
4
5
6
7
@dataclass
class ValueRange:
    lo: int
    hi: int
	
T1 = Annotated[int, ValueRange(-10, 5)]
T2 = Annotated[T1, ValueRange(-20, 3)]

TypeVar

This is a special type for generic types.

1
2
3
4
5
6
7
from typing import Sequence, TypeVar, Iterable

T = TypeVar("T")  # `T` is typically used to represent a generic type variable

def batch_iter(data: Sequence[T], size: int) -> Iterable[Sequence[T]]:
    for i in range(0, len(data), size):
        yield data[i:i + size]

Since the generic type is used, batch_iter function can take any type of Sequence type data. For instance, Sequence[int], Sequence[str], Sequence[Person]

If we use bound, then we can restrict the generic type. For example,

1
2
3
4
5
6
7
from typing import Sequence, TypeVar, Iterable, Union

T = TypeVar("T", bound=Union[int, str, bytes])

def batch_iter(data: Sequence[T], size: int) -> Iterable[Sequence[T]]:
    for i in range(0, len(data), size):
        yield data[i:i + size]

Thus, the following code will show an error as it takes a list of float numbers:

1
batch_iter([1.1, 1.3, 2.5, 4.2, 5.5], 2)

Note that in Python 3.12, generic type hint has been changed

Reference