loading...
Cover image for 📝Python's Type Annotations 📝- Why You Always Should Use It

📝Python's Type Annotations 📝- Why You Always Should Use It

mikhailraevskiy profile image Mikhail Raevskiy Originally published at raevskymichail.Medium ・6 min read

Alt Text

Python is a dynamically typed language and allows us to operate fairly freely on variables of different types. However, when writing code, we somehow assume which types of variables will be used (this may be caused by a limitation of the algorithm or business logic). And for the program to work correctly, it is important for us to find errors associated with transferring data of the wrong type as early as possible.

Keeping the idea of ​​dynamic duck typing in modern versions of Python (3.6+) supports annotations of variable types, class fields, arguments, and return values ​​of functions:

Type annotations are read by the Python interpreter and are not processed in any way but are available for use from third-party code and are primarily designed for static analyzers.

In this article, I want to explain the basics and give examples of using type annotations and eventually show why it made my life as a Python developer much easier 🙂.

First, let's understand what type annotations are


▶ Type annotations - The basics

The types themselves are used to indicate the basic types of variables:

  • str
  • int
  • float
  • bool
  • complex
  • bytes
  • etc.

Unlike older versions of Python, type annotations are not written in comments or docstrings but directly in the code. On the one hand, this breaks backward compatibility. On the other, it clearly means that this is part of the code and can be processed accordingly.

In the simplest case, the annotation contains the directly expected type. More complex cases will be discussed below. If a base class is specified as annotation, it can pass instances of its descendants as values. However, you can use only those capabilities that are implemented in the base class.

Variable annotations are written with a colon after the identifier. This can be followed by value initialization. For instance:

price: int = 5
title: "str"
Enter fullscreen mode Exit fullscreen mode

Function parameters are annotated in the same way as variables, and the return value is specified after the arrow -> and before the trailing colon. Let me give an example of using type annotations in a python function:

def func(a: int, b: float) -> str:
    a: str = f"{a}, {b}"
    return a
Enter fullscreen mode Exit fullscreen mode

For class fields, annotations must be specified explicitly when the class is defined. However, analyzers can automatically infer them based on the __init__ method, but in this case, they will not be available at runtime:

class Book:
    title: "str"
    author: str

    def __init__(self, title: "str, author: str) -> None:"
        self.title = title
        self.author = author

b: Book = Book(title="Fahrenheit 451", author="Bradbury")
Enter fullscreen mode Exit fullscreen mode

▶ Type annotations - Built-in types

Although you can use standard types as annotations, there is a lot of useful stuff hidden in the module typing. Let's take a look at its' sub-modules.

1️⃣ Optional

If you mark a variable with a type int and try to assign to it None, there will be an error:

Incompatible types in assignment (expression has type "None", variable has type "int")

Exactly for such cases, the typing module provides an annotation Optional indicating a specific type. Please note that the type of an optional variable is indicated in square brackets:

from typing import Optional

amount: int
amount: None # Gives "Incompatible types" error

price: Optional[int]
price: None # Will work!
Enter fullscreen mode Exit fullscreen mode

2️⃣ Any

Sometimes you don't want to restrict the possible types of a variable. For example, if it really doesn't matter, or if you plan on doing different types of handling yourself. In this case, annotation can be used Any. It will not swear at the following code:

some_item: Any = 1
print(some_item)
print(some_item.startswith("hello"))
print(some_item // 0)
Enter fullscreen mode Exit fullscreen mode

The question may arise, why not use object? However, in this case, it is assumed that although any object can be passed, it can only be treated as an instance object:

some_object: object
print(some_object)
print(some_object.startswith("hello)) # ERROR: "object" has no attribute "startswith"

print(some_object // 0) # ERROR: Unsupported operand types for // ("object" and "int")
Enter fullscreen mode Exit fullscreen mode

3️⃣ Union

For cases when it is necessary to allow the use of not all types, but only some, you can use the annotation typing.Union indicating the list of types in square brackets.

def hundreds(x: Union[int, float]) -> int:
    return (int(x) // 100) % 100

hundreds(100.0)
hundreds(100)
hundreds("100")
# ERROR: Argument 1 to "hundreds" has incompatible type "str"; expected "Union[int, float]"
Enter fullscreen mode Exit fullscreen mode

By the way, the annotation Optional[T] is equivalent toUnion[T, None], although such a notation is not recommended.

4️⃣ Collections

The mechanism of type annotations supports the mechanism of generics (PEP484 - Generics, for more details in the second part of the article), which allows specifying the types of elements stored in them for containers.

5️⃣ Lists

To indicate that a variable contains a list, you can use the list type as an annotation. However, if you want to specify which elements the list contains, such an annotation will no longer work. For this, there is typing.List. Similar to the way we specified the type of an optional variable, we specify the type of the list items in square brackets.

titles: List[str] = ["hello", "world"]
titles.append(100500)
# ERROR: Argument 1 to "hundreds" has incompatible type "str"; expected "Union[int, float]"

titles = ["hello", 1]
# ERROR: List item 1 has incompatible type "int"; expected "str"

items: List = ["hello", 1]
# Everything is good!
Enter fullscreen mode Exit fullscreen mode

The list is assumed to contain an indefinite number of similar items. But at the same time, there are no restrictions on annotation elements: You can use the Any, Optional, List, and others. If no element type is specified, it is assumed to be Any.

In addition to the list, there are similar annotations for sets: typing.Set and typing.FrozenSet.

6️⃣ Tuples

Tuples, unlike lists, are often used for different types of elements. The syntax is similar with one difference: the type of each element of the tuple is indicated in square brackets separately.

If you plan to use a tuple similarly to a list: store an unknown number of elements of the same type, you can use the ellipsis (...).

Annotation Tuple without specifying element types works the same way as Tuple[Any, ...]

price_container: Tuple[int] = (1,)
price_container: ("hello")
# ERROR: Incompatible types in assignment (expression has type "str", variable has type "Tuple[int]")

price_container = (1, 2)
# ERROR: Incompatible types in assignment (expression has type "Tuple[int, int]", variable has type "Tuple[int]")

price_with_title: Tuple[int, str] = (1, "hello")
# Everything is good!

prices: Tuple[int, ...] = (1, 2)
prices: (1,)
prices: (1, "str")
# ERROR: Incompatible types in assignment (expression has type "Tuple[int, str]", variable has type "Tuple[int]")

something: Tuple = (1, 2, "hello")
# Everything is good!
Enter fullscreen mode Exit fullscreen mode

7️⃣ Dictionaries

Used for dictionaries typing.Dict. Key type and value type are annotated separately:

book_authors: Dict[str, str] = {"Fahrenheit 451": "Bradbury"}
book_authors["1984"] = 0
# ERROR: Incompatible types in assignment (expression has type "int", target has type "str")

book_authors[1984] = "Orwell"
# ERROR: Invalid index type "int" for "Dict[str, str]"; expected type "str"
Enter fullscreen mode Exit fullscreen mode

Similarly used typing.DefaultDict and typing.OrderedDict

8️⃣ Function execution results

Any type annotations can be used to indicate the type of function result. But there are a few special cases.

If the function returns nothing (like how print), its result is always equal None. We also use for annotation None.

The correct options for completing such a function are: explicit return None, return without specifying a value, and termination without a call return:

def nothing(a: int) -> None:
    if a == 1:
        return
    elif a == 2:
        return
    elif a == 3:
        return "" # No return value expected
    else:
        pass

Enter fullscreen mode Exit fullscreen mode

If the function never returns control (for example, how sys.exit), use the annotation NoReturn:

def forever() -> NoReturn:
    while True:
        pass
Enter fullscreen mode Exit fullscreen mode

If this is a generator function, that is, its body contains an operator yield, you can use the annotation for the returned one Iterable[T], either Generator[YT, ST, RT]:

def generate_two() -> Iterable[int]:
    yield 1
    yield "2"
    # ERROR: Incompatible types in "yield" (actual type "str", expected type "int")
Enter fullscreen mode Exit fullscreen mode

Instead of a conclusion

For many situations, the typing module has suitable types, but I will not cover everything, since the behavior is similar to those described. For example, there Iterator is a generic version for collections.abc.Iterator, typing.SupportsInt to indicate that an object supports a method __int__ or Callable for functions and objects that support a method __call__

The standard also defines the format of annotations in the form of comments and stub-files, which contain information only for static analyzers.

Read More

If you found this article helpful, click the💚 or 👏 button below or share the article on Facebook so your friends can benefit from it too.

https://subscribe.to/raevskymichail

Discussion

pic
Editor guide
Collapse
justinhodev profile image
Justin Ho

Nice write-up Mikhail!

It's pretty interesting that the 3 most used dynamically typed languages: Javascript (typescript), PHP, and Python have been including more type annotations / type definitions for development time guarantees / static analysis while statically typed languages like java and c# started adding dynamic types (var) for more flexibility.

Maybe the convergence is the "ideal" language to use? I certainly prefer this mix like typescript over regular javascript.

Collapse
hanpari profile image
Pavel Morava

Static typing is a subset of dynamic typing, so it is feasible to provide optional static typing for dynamically typed languages. However, there are a few misconceptions in your comment.

Firstly, var in C# has nothing to do with dynamic types. It is just a modern approach in modern static typed languages called type inference and has nothing in common with dynamic typing. It was developed to reduce enormous boilerplate, which plagued these languages.

As for C#, it really got recently a real dynamic type called Dynamic, but its purpose is rather to enhance interoperability between C# and dynamically typed languages such as IronPython.

Typescript is presented as statically typed, but in contrast to C# (nominal static typing), it adopts even structural typing, which is closer to Javascript or Python. The dynamic type here is called Any.

But overall, your observation is correct. Languages tend to converge because they keep borrowing features from other languages. Even Java tries a bit, but frankly speaking, with the effort which Jetbrains had put into Kotlin, Java feels completely outdated.

Collapse
marqueiv profile image
Mark A. Donohoe

Good article but you have lots of typos and corrections that you need to make. Go back and read your code examples and you’ll see what I mean about the inconsistencies.

As an example...

def __init__(self, title: "str, author: str) -> None:"
        self.title = title
        self.author = author
Enter fullscreen mode Exit fullscreen mode

Notice the quotes.

And other examples you’re passing the string “100” but in the comments explaining the error you say the string is “hundred”

Collapse
hasii2011 profile image
Humberto A Sanchez II

Just wondering if you have any thoughts/comments on using NewType vs. type equivalencies. By that I mean, creating a type like:

MethodName = str

vs.

MethodName = NewType('MethodName', str)

The former makes the PyCharm type checker happy, but fails when using the 'mypy' linter.

The latter makes 'mypy' happy, but causes a lot of extra casting and syntactic sugar to keep PyCharm happy.

Collapse
hasii2011 profile image
Humberto A Sanchez II

I really hope to hear you on this?

Collapse
sobolevn profile image
Nikita Sobolev

Great article! Don't forget to check out awesome-python-typing collection.

GitHub logo typeddjango / awesome-python-typing

Collection of awesome Python types, stubs, plugins, and tools to work with them.

Awesome Python Typing Awesome Gitter

Collection of awesome Python types, stubs, plugins, and tools to work with them.

Contents

Full list of typed projects on PyPi is here.

Static type checkers

  • mypy - Optional static typing for Python 3 and 2 (PEP 484).
  • pyre - Performant type-checker for Python 3.
  • pytype - Tool to check and infer types for Python code - without requiring type annotations.
  • PyCharm - IDE for Professional Developers.
  • pyright - Fast type checker meant for large Python source bases. It can run in a “watch” mode and performs fast incremental updates when files are modified.
  • pyanalyze - Extensible static analyzer and type checker for Python.

Dynamic type checkers

  • pytypes - Provides a rich set of utilities for runtime typechecking.
  • pydantic - Data parsing using Python type hinting. Supports dataclasses.
  • typeguard - Another…
Collapse
codingcarter profile image
Coding Carter

My god, I didn't even know there were type annotations in Python!

Collapse
hentaichan profile image
ヘンタイちゃん

You should definitely start reading the release notes if you use python on a regular basis, you're missing out big time on all the new features!