Designing Data Contracts
This is the Layer 4 gate. We formalize the informal "contract" from Session 27 using type hints and dataclasses, catching shape mismatches automatically instead of hoping.
An agreed, enforced shape every record must honor.
1. Learning Objective
By the end of this session you will be able to:
- Add type hints to a function or method signature
- Define a class using @dataclass instead of a hand-written __init__
- Explain what type hints do and do not enforce at runtime
- Validate that a raw dict matches the expected contract before constructing an instance from it
- Compare the dataclass version of Country to the hand-written version from Session 13
2. Pre-Coding Quiz
Answer these before reading the concept explanation or writing any code. It is fine to get these wrong โ that is the point. You need 4/5 to proceed to the lab.
Given def summary(country: dict) -> str:, does Python prevent you from calling summary(42) at runtime?
What does @dataclass generate automatically for a class, compared to the hand-written __init__ from Session 13?
Given @dataclass\nclass Country:\n name: str\n region: str\n population: int, does this dataclass automatically give you the Session 19 value-based __eq__ behavior?
A raw dict from a JSON file is missing the "population" key. If you call Country(**raw_dict) where Country is a dataclass requiring population, what happens?
Why would you validate that a raw dict has the correct keys and roughly correct types BEFORE passing it into a dataclass constructor, given that type hints are not enforced at runtime?
Country(name="Kenya", region="Africa", population="fifty-four-million") without complaint at construction time โ the bug would only surface later, confusingly, wherever population is actually used as a number. Explicit validation (Session 24's discipline) catches this immediately, with a clear message.3. The Concept โ Data Contracts with Type Hints and Dataclasses
@dataclass reads the field declarations and generates __init__ and __eq__ for you โ the same result as Sessions 09 and 15, without the boilerplate.
Type hints โ documentation, not enforcement
A type hint tells readers (and tools like editors and type checkers) what type a value is expected to be. Python itself does not check this at runtime โ it is a critical fact to internalize, since it is easy to assume otherwise.
def summary(country: dict) -> str:
return f"{country['name']} ({country['region']})"
# This "should" be wrong according to the type hint, but Python runs it anyway:
print(summary(42))
# TypeError happens INSIDE the function body (42 is not subscriptable),
# not because the type hint was checked and enforced upfront
@dataclass โ less boilerplate, same underlying class
A dataclass declares its fields with type hints, and Python auto-generates __init__ (and by default, __eq__) from those declarations โ directly replacing Session 13's hand-written boilerplate.
from dataclasses import dataclass
@dataclass
class Country:
name: str
region: str
population: int
kenya = Country(name="Kenya", region="Africa", population=54000000)
print(kenya.name) # "Kenya" โ same as before
print(kenya.population) # 54000000
peru1 = Country(name="Peru", region="Americas", population=33000000)
peru2 = Country(name="Peru", region="Americas", population=33000000)
print(peru1 == peru2) # True โ free, field-by-field equality (Session 19's manual __eq__, generated automatically)
Type hints still don't stop bad values at construction
A dataclass will happily accept a value of the wrong type โ the hint is not checked. This is why explicit validation, at the boundary where raw external data enters the program, is still necessary.
bad = Country(name="Kenya", region="Africa", population="fifty-four-million")
print(bad.population) # "fifty-four-million" โ a string, accepted without complaint!
# This will fail confusingly later, wherever population is actually used as a number
Validating the contract before construction
Combining Session 24's validation discipline with the dataclass gives us both convenience AND safety: check the raw dict's shape and types explicitly, THEN construct.
def validate_country_record(data):
required = {"name": str, "region": str, "population": int}
for key, expected_type in required.items():
if key not in data:
raise ValueError(f"missing required field: {key}")
if not isinstance(data[key], expected_type):
raise TypeError(f"{key} must be {expected_type.__name__}, got {type(data[key]).__name__}")
return data
def build_country(data):
validate_country_record(data)
return Country(**data)
4. Lab
What you will build
A file called contracts_lab.py.
Step-by-step instructions
Create the file with Country as a dataclass
# contracts_lab.py
from dataclasses import dataclass
@dataclass
class Country:
name: str
region: str
population: int
def summary(self):
return f"{self.name} ({self.region}): pop. {self.population:,}"
Prove the free __eq__ works, connecting back to Session 19
a = Country(name="Kenya", region="Africa", population=54000000)
b = Country(name="Kenya", region="Africa", population=54000000)
print("a == b:", a == b) # True โ generated automatically
print("a is b:", a is b) # False โ still separate objects
Prove type hints are not enforced at construction
bad = Country(name="Kenya", region="Africa", population="not a number")
print(bad.population) # accepted anyway!
print(type(bad.population)) # <class 'str'> โ the hint did nothing to stop this
Write validate_country_record() checking both presence and type
def validate_country_record(data):
required = {"name": str, "region": str, "population": int}
for key, expected_type in required.items():
if key not in data:
raise ValueError(f"missing required field: {key}")
if not isinstance(data[key], expected_type):
raise TypeError(f"{key} must be {expected_type.__name__}, got {type(data[key]).__name__}")
return data
Validate a batch of raw records, including a deliberately bad one, and skip failures gracefully
Reuse the Session 18 pattern of catching per-record errors without losing the whole batch.
raw_records = [
{"name": "Kenya", "region": "Africa", "population": 54000000},
{"name": "Bad Data", "region": "Africa", "population": "fifty"}, # wrong type
{"name": "Also Bad", "region": "Africa"}, # missing key
]
good_countries = []
for r in raw_records:
try:
validate_country_record(r)
good_countries.append(Country(**r))
except (ValueError, TypeError) as e:
print(f"Rejected {r!r}: {e}")
print("Valid countries:", len(good_countries))
5. Expected Files Changed
| File | Action | Why |
|---|---|---|
contracts_lab.py |
Created | Converts Country to a dataclass and adds explicit contract validation for raw records. |
docs/sessions/session-30/index.html |
Created | This session document โ Layer 4 gate. |
6. Commit Checkpoint
Once the lab is complete and you can explain every line, make this exact commit:
git add contracts_lab.py docs/sessions/session-30/index.html
git commit -m "session-30: formalize the country data contract with dataclasses and explicit validation"
7. Code Review Checklist
Go through your code line by line and check each item:
8. Post-Coding Quiz
Same 5 questions. Take it again now that you have written and run the code. You need 4/5 to mark this session complete.
Given def summary(country: dict) -> str:, does Python prevent you from calling summary(42) at runtime?
What does @dataclass generate automatically for a class, compared to the hand-written __init__ from Session 13?
Given @dataclass\nclass Country:\n name: str\n region: str\n population: int, does this dataclass automatically give you the Session 19 value-based __eq__ behavior?
A raw dict from a JSON file is missing the "population" key. If you call Country(**raw_dict) where Country is a dataclass requiring population, what happens?
Why would you validate that a raw dict has the correct keys and roughly correct types BEFORE passing it into a dataclass constructor, given that type hints are not enforced at runtime?
Country(name="Kenya", region="Africa", population="fifty-four-million") without complaint at construction time โ the bug would only surface later, confusingly, wherever population is actually used as a number. Explicit validation (Session 24's discipline) catches this immediately, with a clear message.9. Reflection Questions
Think through these after the post-quiz. No right answer โ they are for discussion.
- Why do you think Python chose not to enforce type hints at runtime by default, when other languages do enforce their type systems? What tradeoff does this represent?
- How does validate_country_record() in this session compare to validate_population() from Session 24? What is genuinely new versus what is the same idea applied at a different level?
- If a real API someday returns population as a string like "54000000" (valid digits, but the wrong TYPE), would your current validate_country_record() accept or reject it? Is that the right behavior?
- How does the dataclass's generated __init__ relate back to everything you learned about __init__ and self in Session 13?
10. What Breaks If This Knowledge Is Missing?
- Trusting type hints as enforcement: A very common and dangerous misconception is believing type hints protect you from bad data at runtime. This session should permanently correct that โ hints inform tooling and readers, but only explicit validation actually protects your program.
- Testing the data layer (Session 35): The tests you write for CountryRepository in Layer 5 will directly exercise validate_country_record() with both good and bad records โ this session's validation logic IS what gets tested.
- Real, messy API data (Layer 7): A real external API is far less trustworthy than your own mock or JSON data โ it can return unexpected types, missing fields, or malformed values at any time. The validation discipline from this session is what stands between that chaos and your application crashing.
11. What We Learned
Python concept mastered: Type hints as documentation (not enforcement), dataclasses as a concise way to define structured classes, and explicit validation as the real protection at data boundaries.
Unlocks: The Country data contract is now explicit, documented, and actually enforced โ the last Layer 4 skill before we start testing everything we've built.
Next session: Session 31 โ Why We Test and What to Test. Layer 5 begins. We start testing this application deliberately, instead of manually re-running scripts to check our work.