
Having spent most of my career working with statically typed languages, I’ve often found Python challenging — especially when trying to understand the structure of data and how it transforms throughout a codebase.
Recently, my team inherited a Python project that lacked type annotations. As we began working on it, we quickly ran into issues in understanding how data was structured and how it changed across different parts of the codebase — making even small modifications risky and time-consuming.
Among other issues, the use of a NoSQL database amplified the challenge, as there no enforced schema to fall back on. Without clear data models, validating and reasoning about the data became difficult.
To address this, one of our first steps was to introduce type hints — a feature I hadn’t fully explored before. To my surprise, this small change had a significant impact on clarity, maintainability, and confidence when navigating and modifying the code.
In this article, I’ll share how adding type annotations can bring structure, readability, and clarity to a Python codebase — especially for teams used to more strictly typed environments.
Why do we need types ?
Languages like Java and C# have long relied on strong type systems. More recently, TypeScript has brought similar benefits to scripting languages — making types more common and valuable across various programming environments. Using types comes with several advantages:
Readability
Types make code easier to understand by clearly showing the structure of data and flow of logic. They also act as documentation for the team.IDE Support
Modern IDEs work better with types — making navigation, refactoring, and error detection much easier.Clear Contracts
Types define what inputs and outputs are expected, helping developers understand how to use functions and endpoints correctly — especially in larger codebases.Error Prevention
Types help catch bugs early by flagging incompatible data changes, making the code more reliable and maintainable.
Using Types in Python
There are multiple ways in which types can be used in the Python world. Some of which are as follows.
Primitive and non-primitive types
The easiest place to start is by using primitive types to annotate your variables. This improves clarity and helps prevent accidental changes to a variable’s type later in the code.
# Without types - unclear what type of data is expected. Any data can be passed
def add(a, b):
return a + b
# With types - cleaner and easier to understand the requirements.
def add(a: float, b: float) -> float:
return a + b
We started by adding these in the code we were working on. As slightly complex types emerged, we started adding type aliases.
2. Type Alias
Starting with Python 3.12, the typing
module supports type aliases using the type
keyword. This allows you to define complex types with more meaningful names, making your code cleaner and easier to read.
# Definition
type Book = dict[str, str|int]
type Books = list[Book]
# Initialization
my_book: Book = {
"name": "Angels and Demons",
"author": "Dan Brown",
"year": 2000
}
# Accessing a value
print(my_book["name"])
With type aliases, complex structures can be represented more simply. For example, Book
can be used throughout your code instead of dict[str, str | int]
. Type aliases are ideal for representing data structures that don’t require behaviour. However, they can sometimes be ambiguous—especially when the structure isn’t clearly defined—making TypedDict
a better fit in those cases.
3. TypedDict
TypedDict is a part of the typing
module and can be used to define a dictionary with a specific set of keys and values. This helps to statically enforce the structure of dicts and prevents wrong keys from being passed.
# Definition
from typing import TypedDict
class Book(TypedDict):
name: str
author: str
year: int
# Intialization
my_book: Book = { name: 'Angels and Demons', author: 'Dan Brown', year: 2000 }
# Accessing a value
print(my_book['name'])
TypedDict
helps eliminate ambiguity in type aliases by requiring all expected fields of a dictionary to be explicitly defined. Instead of using a generic dict[str, str | int]
, we can specify the exact type for each field, making the code more precise.
4. Data class
Python’s dataclass
is a great way to define simple objects that primarily store data. It reduces boilerplate by automatically generating the constructor and common methods like __init__
, __repr__
, and __eq__
. It also has the flexibility to add custom methods if needed, making it a clean and readable way to model data.
from dataclasses import dataclass
@dataclass
class Book:
name: str
author: str
year: int
# Initialization
my_book = Book('Angels and Demons', 'Dan Brown', 2000)
# Accessing a value
print(my_book.name)
Data classes are ideal for representing models and objects without much behaviour — such as records from a database or data exchanged with external systems. They are like Python’s version of a POJO (Plain Old Java Object). If the object has significant logic or behaviour, using regular classes might be a better choice.
5. Classes
Regular Python classes are well-suited for defining complex types, especially when models require full customisability, advanced validations, and rich behaviour. They provide the flexibility needed for domain-driven design and are ideal when simple data containers like dataclass
or TypedDict
fall short. It’s strongly recommended to use type hints within these classes to maintain type safety and improve code readability.
# Definition
class Book:
name: str
author: str
year: int
def __init__(self, name: str, author: str, year: int):
self.name = name
self.author = author
self.year = year
# ... more behaviour
# Initialization
my_book = Book('Angels and Demons', 'Dan Brown', 2000)
# Access
print(my_book.name)
In our case, we used regular classes for the core domain models that encapsulate business logic and are reused across the codebase. To ensure data integrity, we integrated Pydantic, which allowed us to validate fields and define custom validators with ease. This approach helped us clearly define the structure of our data and reliably validate input, especially for data received from API calls.
6. typing
module
The typing
module in Python provides extensive support for type hints. It provides multiple options including but not limited to type alias, annotations, generics and other options for different types of type hints to be used in Python code. These tools help bring clarity and consistency to the code.
In addition to these, Python provides useful features and tools to support typing:
mypy is a static type checker that can be used to ensure correct use of types and variables. This was used in our project.
There are other type checkers available in the market like Pyright/Pylance, Pyre, Pytype and so on
from __future__ import annotations
can be used for forward references. In terms of type hints, using this, a class can reference itself or another class defined later within the same module.

Although Python is traditionally a dynamically typed scripting language, incorporating type hints can offer significant benefits. From improving code readability and maintainability to enhancing the overall developer experience, types bring a level of structure that helps prevent bugs and supports more robust applications.
Embracing types in Python isn’t just about formality — it’s about writing cleaner, safer, and more reliable code.