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 it29.97
{'price': <class 'float'>, 'quantity': <class 'int'>, 'return': <class 'float'>}
ababCollections, 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)){'to': 2, 'be': 2, 'or': 1, 'not': 1}
no user
50Checking 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.pyshop.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"))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))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__)True Version(major=3, minor=14, patch=1)
{'major': 3, 'minor': 14, 'patch': 1}
Version(major=3, minor=13, patch=0)
FrozenInstanceErrorTypedDict 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())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) andX | Nonefor optional values. - Hints are not enforced at runtime; validate external data separately.
@dataclassgenerates__init__,__repr__and__eq__; usedefault_factoryfor mutable defaults.frozen,orderandslotsoptions, plusTypedDictandProtocol, cover most modelling needs.
# Write your solution here
Finished reading? Mark this lesson complete to track your progress.
