Skip to content
Educora
Advanced22 min18 / 42

Type hints and dataclasses

Writing types into your code: `list[int]`, `int | None`, `Callable`, `Literal`, generics and `Protocol`, checking with mypy, plus `@dataclass`, `field`, `frozen`, `order` and `slots`.

Check yourself
In this lesson you will learn
  • Annotate functions and variables and explain that hints are not checked at run time
  • Describe complex types with Callable, Literal, generic functions and Protocol
  • Write a data class with @dataclass, field(default_factory=...) and __post_init__
  • Choose the frozen, order and slots options appropriately

Look at def price(total, discount): is discount 0.1 or 10? Can total be None? In a small script you remember; in a 50 000-line project with five developers you do not. Type hints write the answer into the code itself: def price(total: float, discount: float) -> float. Editors use them for autocompletion, and checkers such as mypy find bugs before the program even runs. Dataclasses build on the same annotations and write the boring parts of a class for you.

Annotating functions and variables

A hint is written after a colon, and the return type after the arrow ->. Since Python 3.9 the built-in collections can be parameterized directly: list[int], dict[str, float], tuple[int, int], set[str]. Since Python 3.10 a union is written with |: int | None means an integer or None. Variables can be annotated too: count: int = 0.

Python
def average(scores: list[float], precision: int = 1) -> float:
    return round(sum(scores) / len(scores), precision)

print(average([4.5, 5, 3.5]))
print(average.__annotations__)
print(average((1, 2)))
▸ Expected output
4.3
{'scores': list[float], 'precision': <class 'int'>, 'return': <class 'float'>}
1.5

The last line matters: the tuple (1, 2) was accepted even though the hint says list[float]. Python does not check hints at run time. They are stored in __annotations__ and read by tools. To actually find mistakes, run a static type checker:

Terminal
$ mypy shop.py
shop.py:12: error: Argument 1 to "average" has incompatible type "str"; expected "list[float]"  [arg-type]
Found 1 error in 1 file (checked 1 source file)
mypy is installed with pip install mypy and analyses the file without running it.

The typing toolbox

  • Callable[[int], int] — a function that takes an int and returns an int.
  • Literal['r', 'w'] — only these exact values are allowed.
  • type Vector = list[float] (Python 3.12+) — a type alias: a short name for a long type.
  • def first[T](items: list[T]) -> T (Python 3.12+) — a generic function: T is a type variable, so for a list[str] the result is a str. Older versions write this with T = TypeVar('T').
  • Any — switches checking off; use it as rarely as possible.
Python
from typing import Callable, Literal

type Vector = list[float]
Mode = Literal['r', 'w']

def scale(v: Vector, k: float) -> Vector:
    return [k * x for x in v]

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

def apply(func: Callable[[int], int], value: int) -> int:
    return func(value)

def describe(mode: Mode) -> str:
    return 'read' if mode == 'r' else 'write'

print(scale([1.0, 2.5], 2))
print(first(['Aysel', 'Murad']), first([], default='nobody'))
print(apply(lambda n: n * n, 7))
print(describe('w'))
▸ Expected output
[2.0, 5.0]
Aysel nobody
49
write

Python's philosophy is duck typing: if an object has the method you need, it will do. typing.Protocol brings this idea into the type system. A protocol describes what an object can do; any class with the right methods matches it automatically, without inheriting from it. This is called structural typing:

Python
from typing import Protocol

class HasArea(Protocol):
    def area(self) -> float: ...

class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side ** 2

class Circle:
    def __init__(self, r: float) -> None:
        self.r = r

    def area(self) -> float:
        return 3.14159 * self.r ** 2

def total_area(shapes: list[HasArea]) -> float:
    return sum(s.area() for s in shapes)

print(round(total_area([Square(2), Circle(1)]), 2))
▸ Expected output
7.14

Square and Circle know nothing about HasArea, yet a type checker accepts both in a list[HasArea], because each has an area() -> float method. A class that forgets area would be reported before the program runs.

dataclasses: classes without boilerplate

A class that mainly stores data needs an __init__ that copies the arguments into attributes, a readable __repr__ and an __eq__ that compares field by field. Writing them by hand is long and error-prone. The decorator **@dataclass reads the annotated class attributes (the fields**) and generates these methods for you:

Python
from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    grade: int
    scores: list[int] = field(default_factory=list)

    def average(self) -> float:
        return sum(self.scores) / len(self.scores) if self.scores else 0.0

a = Student('Aysel', 9, [5, 4, 5])
b = Student('Aysel', 9, [5, 4, 5])
print(a)
print(a == b, a is b)
print(round(a.average(), 2))
print(Student('Murad', 8))
▸ Expected output
Student(name='Aysel', grade=9, scores=[5, 4, 5])
True False
4.67
Student(name='Murad', grade=8, scores=[])

a == b is True because the generated __eq__ compares the fields, while a is b is False — they are two separate objects. The field scores got a fresh empty list for Murad thanks to field(default_factory=list): the factory is called separately for every new object.

Python
from dataclasses import dataclass

try:
    @dataclass
    class Basket:
        items: list[str] = []
except ValueError as e:
    print(e)
▸ Expected output
mutable default <class 'list'> for field items is not allowed: use default_factory

frozen, order, slots and __post_init__

The decorator's parameters switch on extra features. frozen=True makes objects immutable (and therefore hashable, so they can be set elements and dictionary keys), order=True generates <, <=, >, >= that compare the fields in order, like tuples, and slots=True (Python 3.10+) stores attributes in __slots__ instead of a dictionary, which saves memory and speeds up access. The method __post_init__ runs right after the generated __init__ — it is the place for validation:

Python
from dataclasses import dataclass, replace, asdict

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

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

v1 = Version(0, 1)
v2 = replace(v1, minor=2)
print(v1 < v2)
print(sorted([Version(1), v2, v1]))
print(asdict(v2))
print({v1, Version(0, 1, 0)})
try:
    v1.major = 1
except AttributeError as e:
    print(type(e).__name__)
▸ Expected output
True
[Version(major=0, minor=1, patch=0), Version(major=0, minor=2, patch=0), Version(major=1, minor=0, patch=0)]
{'major': 0, 'minor': 2, 'patch': 0}
{Version(major=0, minor=1, patch=0)}
FrozenInstanceError

Let's read the output. sorted works thanks to order=True: versions are compared like (major, minor, patch) tuples. The set kept one element, because equal frozen objects also have equal hashes. The attempt to assign raised FrozenInstanceError, a subclass of AttributeError — once created, the object cannot be changed.

OptionWhat it gives
frozen=Trueimmutable objects; assignment raises FrozenInstanceError; hashing
order=Truefield-by-field comparison < <= > >=, so objects can be sorted
slots=True__slots__: less memory, faster access, no new attributes
kw_only=Trueall fields must be passed by name
field(default_factory=...)a fresh default value for every object
field(repr=False, compare=False)leaves a field out of repr or comparisons
Exercise

Complete the Cart dataclass: add a field items that is an empty dictionary by default (every cart must get its own), make add store a product with its price, and make total return the sum of the prices rounded to 2 decimals.

Exercise · Python
from dataclasses import dataclass, field

@dataclass
class Cart:
    owner: str
    # add a field `items`: dict[str, float], an empty dict by default

    def add(self, name: str, price: float) -> None:
        ...

    def total(self) -> float:
        ...

a = Cart('Aysel')
b = Cart('Murad')
a.add('tea', 3.5)
a.add('bread', 0.8)
print(a)
print(b)
print(a.total())
▸ Expected output
Cart(owner='Aysel', items={'tea': 3.5, 'bread': 0.8})
Cart(owner='Murad', items={})
4.3
Exercise

The runners must be sorted by time (fastest first) and by name when the times are equal. Without using a key argument, change the class so that sorted() does exactly that.

Exercise · Python
from dataclasses import dataclass

@dataclass(order=True, frozen=True)
class Runner:
    name: str
    time: float

results = [Runner('Murad', 12.4), Runner('Leyla', 11.9), Runner('Aysel', 12.4)]
for r in sorted(results):
    print(r.name, r.time)
▸ Expected output
Leyla 11.9
Aysel 12.4
Murad 12.4

Key points

  • Hints (x: int, -> str, list[int], int | None) document types; Python does not enforce them at run time.
  • mypy or pyright check the hints statically, before the program runs.
  • Generics (def first[T]), Callable, Literal and Protocol describe richer contracts; Protocol is structural typing.
  • @dataclass generates __init__, __repr__ and __eq__ from annotated fields; mutable defaults need field(default_factory=...).
  • frozen, order, slots and __post_init__ add immutability, ordering, memory savings and validation.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
What happens at run time if you pass a string to a function whose parameter is annotated x: int?