Python is a dynamically typed language. Is this good or bad? On one hand, it speeds up development: you don't need to declare or remember variable types. On the other hand, it can lead to errors that surface during runtime... or a month into production.
In this article, I'll show why type hinting is a tool that will save hours of debugging and make your code safer.
Let's get started.
Type annotations were first introduced in Python 3.5, released in 2015. Yet many developers still write code in the old style. Let's take a typical piece of code from a typical developer:
def process_user_data(user_data):
return user_data['name'].upper()
At first glance, this appears to be a simple function with a reasonable name. But it hides several problems:
- Opaque contract - without examining the implementation, it's impossible to understand:
- What data types are expected?
- What structure should user_data have?
- Fragility - it's unclear what the function will return in case of an error
Let's improve the code and examine each element
from typing import TypedDict
class UserData(TypedDict):
id: int
name: str
def get_user_name(user_data: UserData) -> str:
return user_data["name"].upper()
How Annotations Work:
- Parameters: arg: type (e.g., user_data: UserData)
- Return value: -> return_type (in our case -> str)
Basic Types (Primitives)
The simplest and most commonly used annotations:
from numbers import Real
def add(a: int, b: int) -> int:
return a + b
def add(a: Real, b: Real) -> Real:
return a + b
def greet(name: str) -> str:
return f"Hello {name}"
def is_active(status: bool) -> bool:
return status
With type annotations, even the simplest functions become clearer and safer, as your IDE will warn you if you try to pass the wrong type.
It's important to remember that your code will still run even with incorrectly passed values. Annotations only serve as hints for the developer.
If you want to learn more about AI and Python development, subscribe to my Telegram channel:
Union, Optional, Literal
Union allows specifying multiple valid types, for example, int/float.
from typing import Union
def add(a: Union[int, float], b: Union[int, float]) -> Union[int, float]:
return a + b
Here, the add function accepts arguments of type int or float and returns a result of the same type.
def get_element(d: dict[str, str], key: str) -> Union[str, None]:
return d.get(key, None)
Optional indicates that a value can be either of type T or None. It is equivalent to Union[T, None].
user_name: Optional[str] = None
Classes with optional fields:
class User:
def __init__(self, name: str, phone: Optional[str] = None):
self.name = name
self.phone = phone
user1 = User("Viacheslav", "+999999999")
user2 = User("Voo")
Literal strictly enforces allowed values. Ideal for:
- Statuses ("active", "pending", "completed")
- HTTP methods ("GET", "POST", "PUT")
- Any constant values
HttpMethod = Literal["GET", "POST", "PUT", "DELETE"]
def send_request(method: HttpMethod, url: str) -> None:
print(f"{method} запрос к {url}")
send_request("POST", "/api") # OK
send_request("PATCH", "/") # ERROR
from typing import Literal
def set_status(status: Literal["active", "inactive", "pending"]) -> None:
print(f"Статус изменён на: {status}")
set_status("active") # OK
set_status("deleted") # ERROR
Annotations for collections: lists, dictionaries, tuples
Python allows typing not only primitives but also complex data structures.
def find_item(d: dict[str, int], key: str) -> int | None:
return d.get(key)
TypedDict
TypedDict allows explicitly defining a dictionary structure:
- Which keys are required
- What value types each key has
from typing import TypedDict
class ServerConfig(TypedDict):
host: str
port: int
ssl: bool
def start_server(config: ServerConfig):
print(f"Starting server with config: {config}")
config: ServerConfig = {"host": "localhost", "port": 8080, "ssl": False}
start_server(config)
TypedDict for Data Validation
Type annotations and typed structures work well for data validation.
class Book(TypedDict):
title: str
year: int
def validate_book(date: dict) -> Book:
required_keys = {"title", "year"}
if not all(key in data for key in required_keys):
return None
return Book(**data)
raw_data = {"title": "Python", "year": 2005}
book: Book | None = validate_book(raw_data)
if book:
print(book["title"])
NamedTuple
Tuples are convenient for storing immutable data, such as DTOs. However, accessing fields by indices ([0], [1]) is unreliable and makes code harder to read. NamedTuple solves this problem.
from typing import NamedTuple
class Product(NamedTuple):
name: str
price: int
quantity: int
def total_value(product: Product) -> int:
return product.price * product.quantity
Callable, Generator
Functions also support annotations. This is useful for higher-order functions, callbacks, and generators. Let's write a function that accepts other functions (with specific arguments) as parameters.
def send_messages(on_success: Callable[[], None]),
on_failure: Callable[[Exception], None],
) -> None:
if random.random() < 0.5:
return on_success()
return on_failure(Exception('something went wrong'))
def on_success() -> None:
print("success")
def on_failure(exception: Exception) -> None:
print(exception)
send_messages(on_success, on_failure)
The annotation for generators looks like:
- YieldType– the type of value the generator yields via yield
- SendType – the type of value passed into the generator via send
- ReturnType – the type of value returned upon generator completion
from typing import Generator
def generator(words: list[str]) -> Generator[str, None, None]:
for word in words:
yield word
words = ["Москва", "Питер", "Казань"]
for word in word_generator(words):
print(word)
Generics
Suppose we want to create a function that returns the first element of a list of any type. Without generics, you would have to use the Any annotation, which would be useless.
from typing import TypeVar
T = TypeVar("T")
def get_first_element(lst: list[T]) -> T | None:
return lst[0] if lst else None
Let's implement a stack that holds values of a single type using Generics:
class Stack(Generic[T]):
def __init__(self):
self.stack: list[T] = []
def push(self, element: T) -> None:
self.stack.append(element)
def pop(self) -> T:
return self.stack.pop()
int_stack = Stack[int]()
int_stack.push(1)
int_stack.push("1")
Custom Annotations (TypeAlias, NewType, Protocol)
TypeAlias
Useful when we need to describe a complex annotation that isn't a data structure and takes up too much space. For example, when writing a type annotation for a function, as in one of the examples above.
from typing import TypeAlias, Callable
func: TypeAlias = Callable[[float, float], float]
def apply_operation(a: float, b: float, op: func) -> float:
return op(a, b)
add_op: func = lambda x, y: x + y
print(apply_operation(1.2, 2.5, add))
Without TypeAlias, the function signature would look cumbersome.
You can also use TypeAlias for recursive references—for example, when annotating JSON-like structures that differ from Python's native types.
from typing import TypeAlias, Union
Json: TypeAlias = Union[
str, int, float, bool, None,
dict[str, 'Json'], # рекурсивная ссылка
list['Json']
]
def parse_json(data: Json) -> None:
print(data)
data: Json = {
"name": "ViacheslavVoo",
"scores": [95, 87, 91],
"metadata": {"active": True, "tags": None},
}
parse_json(data)
Here it's worth noting that the IDE may not show an error if incorrect types are used. To validate complex or recursive annotations, it's best to use mypy, which will detect type mismatches.
For example, if you mistakenly use tuple instead of dict in a JSON-like structure, mypy will raise an error:
error: Dict entry 0 has incompatible type "str": "tuple[int, int, int]"; expected "str": "str | int | float | dict[str, Json] | list[Json] | None" [dict-item]
Found 1 error in 1 file (checked 1 source file)
NewType
This type of annotation should be used when you need to create a distinct new type based on an existing one. The key difference from other annotations is that NewType enforces type separation—meaning NewType('X', int) != int, unlike TypeAlias.
This makes it useful for semantic differentiation, ensuring that values of the same underlying type (like int) are not accidentally mixed.
from typing import NewType
UserId = NewType("UserId", int)
OrderId = NewType("OrderId", int)
def get_user(UserId) -> None:
...
user_id = UserId(1)
order_id = OrderId(1)
get_user(user_id)
get_user(order_id) #mypy покажет ошибку, так как UserId и OrderId - разные типы
get_user(1)
Note:
- Before Python 3.10, NewType was a zero-overhead abstraction (erased at runtime).
- In Python 3.10, it became a wrapper class, adding minor runtime overhead.
- Python 3.11+ restored performance to pre-3.10 levels (near-zero cost).
Protocol
The Protocol can be used when creating interfaces that you do not want to explicitly implement or cannot inherit from. Any class that has a draw method will conform to the Protocol. This feature is extremely useful for testing.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> None:
...
class Circle:
def draw(self) -> None:
print("draw circle")
def some_func(self):
print("some_func")
def render(obj: Drawable):
obj.draw()
render(Circle())
Typing in Python is not about strict constraints but a way to make code clearer, more reliable, and more convenient. Use type hinting in your projects—it will save you hours of refactoring and spare the nerves of new team members maintaining your code.
Thanks for reading!
Follow me on Telegram to stay updated on new articles.
My channel
Top comments (0)