LESSON 37 · INTERMEDIATE4 min read

Project: A Typed Data Model Library

Build a small order library with frozen dataclasses, exact money, a Protocol discount boundary, and a green mypy --strict run.

Python 3.12+Free to readModule 7 of 19
YOUR NEXT SMALL WINS

By the end, you’ll be able to…

  • Model domain records with frozen dataclasses and validation.
  • Represent money with Decimal and prevent invalid states at construction.
  • Type a replaceable discount boundary with a Protocol.
  • Check the public contract with mypy --strict and behavioral tests.

01Model the domain before the plumbing

A typed data library is valuable only when its types describe real rules. Start with the nouns the application exchanges: a product, an order line, and a discount. Write down the invariants that must hold for every instance, such as a positive quantity, a nonblank SKU, and a price that is never negative.

A frozen dataclass is a good default for a value object. It generates initialization, equality, and a readable representation, while frozen=True prevents accidental rebinding after construction. Validation still belongs in __post_init__: the type system can say that a quantity is an int, but it cannot say that the int must be greater than zero.

catalog.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

02Make invalid states unrepresentable

Money deserves Decimal, not float. Binary floating point cannot represent 0.10 exactly, so repeated arithmetic drifts. Decimal keeps base-10 precision, and an explicit quantization step makes rounding a documented policy rather than an accident. Keep amounts in their smallest unit or quantize at every boundary, but never mix the two approaches.

An order line owns its quantity and unit price and exposes a derived total. Because the dataclass is frozen, callers cannot silently change a quantity after the line has been priced. If a change is required, create a new line. That rule makes an order easy to audit and prevents one mutation from invalidating a total that was already computed elsewhere.

order_line.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

03Type the boundary, not every local

A Protocol describes the behavior a caller needs without forcing implementations to inherit from a base class. The checkout function only needs an object with an apply(subtotal) method, so a percentage discount, a fixed discount, and a test double can all satisfy the same annotation. This is structural typing: the shape of the object matters, not its family tree.

Annotate the public boundary precisely. Sequence[OrderLine] accepts a list or a tuple without promising mutation, Discount | None states that the argument is optional, and the return type commits the function to a Decimal. Internal locals can be inferred. The result is a contract that editors and mypy can check while the runtime remains ordinary Python.

checkout.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

04Check the contract with mypy and tests

Install mypy into the project environment and run it in strict mode against the library. Strict mode enables checks such as disallowing untyped definitions, so every public function needs parameter and return annotations. Read the first error, fix the annotation or the implementation, and rerun. Do not add type: ignore until you can explain why the checker is wrong.

Static checking is only half of the evidence. A test should prove that a zero quantity is rejected, that a percentage discount produces the expected total, and that a different Discount implementation works without changing checkout. Together, mypy verifies the shape of the contract and tests verify the behavior behind it.

typecheck.shShell
py -m pip install mypy pytestpy -m mypy --strict catalog.py order_line.py checkout.pypy -m pytest -q
LET IT CLICK

The ideas worth keeping.

  • A frozen dataclass documents a value and blocks accidental rebinding.
  • Validation in __post_init__ turns business rules into construction-time guarantees.
  • Protocols type a boundary by behavior, so implementations stay replaceable.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

Make it yours.

Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.

01

Add an Email value object

Create a frozen Email dataclass with one value field. Normalize whitespace and case, require exactly one @, and require a dotted domain. Print the normalized value and one rejected example.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
from dataclasses import dataclass@dataclass(frozen=True)class Email:    value: str    def __post_init__(self) -> None:        cleaned = self.value.strip().lower()        if cleaned.count("@") != 1:            raise ValueError("email must contain one @")        local, domain = cleaned.split("@")        if not local or "." not in domain:            raise ValueError("email must have a local part and a dotted domain")        object.__setattr__(self, "value", cleaned)print(Email("  Ada@Example.COM ").value)try:    Email("not-an-email")except ValueError as exc:    print(exc)

__post_init__ enforces the rules a type checker cannot express. object.__setattr__ is required because the dataclass is frozen and ordinary assignment would raise FrozenInstanceError.

02

Implement a fixed-amount discount

Define a FixedOff dataclass that satisfies the Discount Protocol: it holds a nonnegative Decimal amount and subtracts it from the subtotal without going below zero.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
from dataclasses import dataclassfrom decimal import Decimalfrom typing import Protocolclass Discount(Protocol):    def apply(self, subtotal: Decimal) -> Decimal: ...@dataclass(frozen=True)class FixedOff:    amount: Decimal    def apply(self, subtotal: Decimal) -> Decimal:        if self.amount < 0:            raise ValueError("discount cannot be negative")        return max(Decimal("0.00"), subtotal - self.amount)print(FixedOff(Decimal("5.00")).apply(Decimal("19.95")))

FixedOff never inherits from a base class; it satisfies Discount structurally. checkout can accept it without modification, which is the point of programming to a small behavior.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01What does frozen=True prevent on a dataclass?
02What does mypy verify?
03Why is Decimal preferred for money?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

Add an Email value object

Create a frozen Email dataclass with one value field. Normalize whitespace and case, require exactly one @, and require a dotted domain. Print the normalized value and one rejected example.

EXPECTED OUTPUT
Email("  Ada@Example.COM ").value
exc
main.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this lesson, when you want a second angle or the full details.

MODULE 07 · PRACTICAL SELF-CHECK

Pause. Put the pieces together.

You’ve reached the end of Type Hints and Modern Python. Before moving on, use this task to check what you can do independently.

Add type hints to a module, run mypy in strict mode, and fix all type errors without changing runtime behavior.

Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.

Review this module

One lesson closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock lesson completion.