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:

  1. 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.

  2. IDE Support
     Modern IDEs work better with types — making navigation, refactoring, and error detection much easier.

  3. Clear Contracts
     Types define what inputs and outputs are expected, helping developers understand how to use functions and endpoints correctly — especially in larger codebases.

  4. 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.

  1. 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.