DEV Community

ViacheslavVoo
ViacheslavVoo

Posted on

Typing in Python: How Annotations Save Your Code and Speed Up Development

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()
Enter fullscreen mode Exit fullscreen mode

At first glance, this appears to be a simple function with a reasonable name. But it hides several problems:

  1. Opaque contract - without examining the implementation, it's impossible to understand:
  • What data types are expected?
  • What structure should user_data have?
  1. 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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

My 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
from typing import Literal

def set_status(status: Literal["active", "inactive", "pending"]) -> None:
    print(f"Статус изменён на: {status}")

set_status("active")  # OK
set_status("deleted")  # ERROR
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)   
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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") 
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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)