Python · Lesson 13 of 21

Type Hints and dataclasses

Add Python type hints to functions and collections, check them with mypy, and replace boilerplate classes with dataclasses, TypedDict and Protocol.

  • Intermediate
  • 18 min read
  • 4 objectives

Before this lessonLesson 12: Inheritance and Special Methods

What you will learn

  • Annotate variables, functions and collections with modern syntax
  • Use Optional values, unions and generics correctly
  • Check code with a static type checker like mypy
  • Replace boilerplate classes with dataclasses

Your Progress

0 of 21 lessons 0%

  • Lessons0 / 21
  • Completed0
  • Est. time left~ 5 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

Python is dynamically typed: a variable can hold anything, and mistakes like passing a string where a number was expected only show up when that line runs. Type hints let you write down what you expect. Python itself ignores them at runtime, but editors and type checkers read them to catch bugs before you run anything, and teammates read them as documentation.

In the second half you will meet dataclasses, which use those same annotations to generate the boring parts of a class for you.

Annotating variables and functions

Put : type after a parameter or variable and -> type after the parameter list for the return value. Hints are stored in __annotations__ but never enforced, which is why the last call still works.

def order_total(price: float, quantity: int = 1) -> float:
    return price * quantity

name: str = "stackcone"
retries: int = 3

print(order_total(9.99, 3))
print(order_total.__annotations__)
print(order_total("ab", 2))   # wrong type, but Python does not stop it
Output
29.97
{'price': <class 'float'>, 'quantity': <class 'int'>, 'return': <class 'float'>}
abab

Collections, unions and Optional

Since Python 3.9 you can use the built-in types directly with square brackets: list[str], dict[str, int], tuple[int, int]. Since 3.10, A | B means "either type". A value that may be missing is written X | None (older code spells this Optional[X] from the typing module).

def count_words(lines: list[str]) -> dict[str, int]:
    counts: dict[str, int] = {}
    for line in lines:
        for word in line.split():
            counts[word] = counts.get(word, 0) + 1
    return counts

def find_user(users: dict[int, str], user_id: int) -> str | None:
    return users.get(user_id)

def parse_id(raw: str | int) -> int:
    return int(raw)

print(count_words(["to be", "or not to be"]))
user = find_user({1: "Ada"}, 2)
if user is not None:          # narrows str | None to str
    print(user.upper())
else:
    print("no user")
print(parse_id("42") + parse_id(8))
Output
{'to': 2, 'be': 2, 'or': 1, 'not': 1}
no user
50

Checking if user is not None is not just good practice; type checkers understand it and know that inside the branch user is a str.

Catching bugs with a type checker

To get value from hints you run a checker. mypy is the long-standing standard, pyright powers VS Code's Pylance, and Astral's fast ty checker is gaining ground. Install one in your project's virtual environment and run it over your code, often in CI.

# shop.py
def apply_discount(price: float, percent: int) -> float:
    return price * (1 - percent / 100)

total = apply_discount("19.99", 10)
label: int = "sale"
pip install mypy
mypy shop.py
shop.py:5: error: Argument 1 to "apply_discount" has incompatible type "str"; expected "float"  [arg-type]
shop.py:6: error: Incompatible types in assignment (expression has type "str", variable has type "int")  [assignment]
Found 2 errors in 1 file (checked 1 source file)

Both bugs were found without running the program. You can add hints gradually: unannotated functions are skipped by default, so start with the most important modules.

Generics, Callable and type aliases

Sometimes a function works for any type but you want to say "the output has the same type as the input". Python 3.12 added a short syntax for this: a type parameter in square brackets after the function name. Callable[[int], str] describes a function that takes an int and returns a str, and the type statement creates a readable alias.

from collections.abc import Callable

def first[T](items: list[T]) -> T | None:
    return items[0] if items else None

type Handler = Callable[[str], str]

def run(handler: Handler, value: str) -> str:
    return handler(value)

print(first([3, 1, 2]))
print(first(["x", "y"]))
print(first([]))
print(run(str.title, "hello stackcone"))
Output
3
x
None
Hello Stackcone

dataclasses: classes without boilerplate

A class that mainly holds data needs an __init__, a readable __repr__ and an __eq__. Writing those by hand is repetitive and easy to get wrong. The @dataclass decorator reads the annotated class attributes and generates all three for you.

from dataclasses import dataclass, field

@dataclass
class Order:
    id: int
    customer: str
    items: list[str] = field(default_factory=list)
    paid: bool = False

    def add(self, item: str) -> None:
        self.items.append(item)

a = Order(1, "Ada")
a.add("keyboard")
b = Order(1, "Ada", ["keyboard"])
print(a)
print(a == b)
print(Order(2, "Linus", paid=True))
Output
Order(id=1, customer='Ada', items=['keyboard'], paid=False)
True
Order(id=2, customer='Linus', items=[], paid=True)

Note field(default_factory=list): a mutable default like [] would be shared by every instance, so dataclasses refuse it and ask for a factory instead.

Frozen, ordered and slotted dataclasses

Options on the decorator change the generated class. frozen=True makes instances immutable (and hashable, so they can be dict keys or set members). order=True adds comparison operators based on field order. slots=True uses less memory per instance. __post_init__ runs after the generated __init__, which is a good place for validation.

from dataclasses import dataclass, asdict, replace

@dataclass(frozen=True, order=True, slots=True)
class Version:
    major: int
    minor: int
    patch: int = 0

    def __post_init__(self):
        if self.major < 0:
            raise ValueError("major must be >= 0")

v1 = Version(3, 12)
v2 = Version(3, 14, 1)
print(v1 < v2, max(v1, v2))
print(asdict(v2))
print(replace(v1, minor=13))
try:
    v1.major = 4
except Exception as err:
    print(type(err).__name__)
Output
True Version(major=3, minor=14, patch=1)
{'major': 3, 'minor': 14, 'patch': 1}
Version(major=3, minor=13, patch=0)
FrozenInstanceError

TypedDict and Protocol

Two more tools from typing come up often. TypedDict describes the shape of a plain dict (useful for JSON data you do not want to convert into objects). Protocol describes behaviour: any object with the right methods matches, with no inheritance needed. This is "duck typing" that a type checker can verify.

from typing import TypedDict, Protocol

class UserJSON(TypedDict):
    id: int
    email: str

class Notifier(Protocol):
    def send(self, to: str, message: str) -> None: ...

class EmailNotifier:
    def send(self, to: str, message: str) -> None:
        print(f"email to {to}: {message}")

def welcome(user: UserJSON, notifier: Notifier) -> None:
    notifier.send(user["email"], "Welcome to stackcone!")

welcome({"id": 1, "email": "ada@example.com"}, EmailNotifier())
Output
email to ada@example.com: Welcome to stackcone!

Recap

  • Type hints document intent and let tools like mypy, pyright or ty catch bugs before runtime.
  • Use built-in generics (list[str]), unions (int | str) and X | None for optional values.
  • Hints are not enforced at runtime; validate external data separately.
  • @dataclass generates __init__, __repr__ and __eq__; use default_factory for mutable defaults.
  • frozen, order and slots options, plus TypedDict and Protocol, cover most modelling needs.
# Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 14File HandlingRead and write text files safely with context managers.