Python Module 11 – Type hints and static type checking
Validating external data at run time
Validate JSON, forms and files at the edge of a Python program with explicit checks, dataclasses and __post_init__, and see when pydantic is worth it.
What you will learn
- Explain why annotations, TypedDicts and dataclasses check nothing while a program runs
- Validate untrusted input with explicit checks that report every problem at once
- Validate dataclasses in __post_init__ and keep the result immutable
- Reproduce the Python-specific inputs that slip past validators, and refuse each one
- Decide when a library such as pydantic is worth adding, with measured numbers
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why annotations, TypedDicts and dataclasses check nothing while a program runs
- Validate untrusted input with explicit checks that report every problem at once
- Validate dataclasses in __post_init__ and keep the result immutable
- Reproduce the Python-specific inputs that slip past validators, and refuse each one
- Decide when a library such as pydantic is worth adding, with measured numbers
Before you start
On this page
A dataclass that declares username: str and age: int accepted a list and the string "twenty" in this lesson’s
first run, and the mistake surfaced only later, as a TypeError in code that had nothing to do with the input. Type
hints describe data, but nothing checks them while the program runs.
A JSON body from a client, a form field, a command-line argument, a row of a file or an environment variable is
whatever the sender sent, so a program must validate it where it enters. Good validation checks every field against
what is allowed, reports every problem at once so the sender can fix them all, and returns a clean, typed object,
usually a frozen dataclass: A dataclass declared with frozen=True. Assigning to a field after the object is created raises FrozenInstanceError, and with the default eq=True the decorator also writes a __hash__ from the fields, so the objects can be dict keys. More in “Dataclasses for clean data objects”. You can write the checks by hand, put them in a
dataclass’s __post_init__, or let a library such as pydantic generate them from type hints. The runs below compare
the three, on CPython 3.14.8 and with pydantic 2.14.1, including what each one costs.
Annotations do nothing with bad data
Step through the program: a dataclass built from bad JSON, and the line where the bad age finally causes trouble.
Program · hints/not_checked.py
"""What annotations do with bad data while the program runs: nothing."""
import json
from dataclasses import dataclass
@dataclass(frozen=True)
class Signup:
username: str
age: int
body = '{"username": ["asha"], "age": "twenty"}'
data = json.loads(body)
signup = Signup(**data)
print(signup)
print(type(signup.username).__name__, type(signup.age).__name__)
try:
print("next year:", signup.age + 1)
except TypeError as error:
print("TypeError, far from the input:", error) Variables
- changed in this step
Printed so far
Nothing printed yetSignup(username=['asha'], age='twenty') list str TypeError, far from the input: can only concatenate str (not "int") to str
Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.
The drawing shows the variables when the program ends, and the program's 17 steps are listed below.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
"""What annotations do with bad data while the program runs: nothing."""runs. - Line 2.
import jsonloadsjson. - Line 3.
from dataclasses import dataclassloadsdataclass. - Line 6. Line 6 runs.
- Line 7.
class Signup:makes the classSignup. Its body runs only when it is used. - Line 6. Line 6 runs.
- Line 7.
class Signup:makes the classSignup. Its body runs only when it is used. - Line 12.
body = '{"username": ["asha"], "age": "twenty"}'setsbodyto'{"username": ["asha"], "age": "twenty"}'. - Line 13.
data = json.loads(body)setsdatato{'username': ['asha'], 'age': 'twenty'}. - Line 14.
signup = Signup(**data)setssignuptoSignup(username=['asha'], age='twenty'). The bug The dataclass stored a list asusernameand the string'twenty'asage. Its__init__was written from the annotations, but it never compares a value with them. - Line 15.
print(signup)printsSignup(username=['asha'], age='twenty'). - Line 16.
print(type(signup.username).__name__, type(signup.age).__name__)printslist str. The types printed here arelistandstr, although the class declaresstrandint. Nothing has complained yet. - Line 17.
try:runs. - Line 18.
print("next year:", signup.age + 1)prints nothing. Key point The mistake surfaces only now, four lines after the data arrived, as aTypeErrorabout adding anintto astr. - Line 19. Line 19 runs.
- Line 20.
print("TypeError, far from the input:", error)printsTypeError, far from the input: can only concatenate str (not "int") to str.errorno longer exists. That was the last line, so the program ends. Nothing checked the data where it entered, so the error names neither the field nor the sender. Validation moves that failure to the edge, with a message the sender can act on.
The dataclass declares a str and an int, and Python built it with a list and a string without a word. A dataclass
uses its annotations to write __init__, and a TypedDict: A class from typing that gives each key of a dictionary its own type, such as id: str and amount: int, for JSON-like records. The value is still a plain dict at run time, and Python checks none of its keys. More in “TypedDict, Literal, NewType and enums” uses them to describe keys, but
neither compares a value with its annotation. The mistake surfaced only when the age was used, as a TypeError about
concatenating text, in code that had nothing to do with the input. That distance between the cause and the crash is
what validation removes: it moves the failure to the one place where the data arrives, and gives it a message the
sender can act on.
The edge of a program, its trust boundary: The line between data your program made and protects and data that someone else could have written or changed, such as an upload, a shared cache or a folder others can write to. Data that crosses it is read with care, in a format that cannot run code. More in “pickle, shelve and safe serialisation”, is wherever data crosses over from something you do not control: an HTTP handler, a command-line parser, the function that reads a CSV file, the code that loads configuration or a message from a queue. Each of these needs its own validator, and code behind them should take the validated objects as parameters, never the raw dictionaries. A second check deep inside the program is usually a sign that some path skips the edge.
Checkpoint
Check: What annotations do with bad data
One validator at the edge reports every problem
"""A validator at the edge: check every field, report every problem at once, return a typed, frozen object."""
import json
from dataclasses import dataclass
class SignupError(ValueError):
def __init__(self, problems: list[str]) -> None:
super().__init__("; ".join(problems))
self.problems = problems
@dataclass(frozen=True)
class Signup:
username: str
email: str
age: int
def parse_signup(data: object) -> Signup:
if not isinstance(data, dict):
raise SignupError(["the body must be a JSON object"])
problems = []
username = data.get("username")
if not isinstance(username, str) or not 3 <= len(username) <= 20 or not username.isascii() or not username.replace("_", "").isalnum():
problems.append("username: 3 to 20 letters, digits or _")
email = data.get("email")
if not isinstance(email, str) or len(email) > 254 or email.count("@") != 1 or "." not in email.split("@")[1]:
problems.append("email: must look like name@example.com")
age = data.get("age")
if type(age) is not int or not 13 <= age <= 120:
problems.append("age: a whole number from 13 to 120")
if problems:
raise SignupError(problems)
return Signup(username, email.lower(), age)
print(parse_signup(json.loads('{"username": "asha_r", "email": "Asha@Example.com", "age": 21}')))
for body in ('{"username": "a!", "email": "asha", "age": "21"}', '[1, 2]'):
try:
parse_signup(json.loads(body))
except SignupError as error:
print("refused:", error.problems) Output
Signup(username='asha_r', email='asha@example.com', age=21) refused: ['username: 3 to 20 letters, digits or _', 'email: must look like name@example.com', 'age: a whole number from 13 to 120'] refused: ['the body must be a JSON object']
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 signup.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The function follows four rules. It tests the shape first: a JSON array is not an object, and checking keys on it
would fail. It checks each field against what is allowed, an allowlist: A list of what is accepted, such as the URL schemes http and https; anything not on it is refused. Unlike a denylist, it also refuses values nobody thought of. More in “XML and HTML parsing without security holes” of characters for
the username, a length limit before any other test, and exact types: type(age) is not int refuses the string "21".
It collects the problems in a list and raises one error with all of them, three for the second body. And it returns a
frozen Signup with normalised values, the email in lower case, so that nothing downstream has to repeat the work.
Follow the second body through the checks:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The shape first. The body is a dict, so keys can be read. Testing the shape first means the field checks never run on something that has no keys.
- The username. "a!" has a character outside the allowlist of letters, digits and underscores, and it is shorter than 3. The problem goes into the list, and the checks go on.
- The email. "asha" has no @ at all. That is the second problem, collected in the same list.
- The age. "21" is a str. The exact test type(age) is not int refuses it, although it looks like a number: the validator converts nothing.
- One error, three problems. The list holds three problems, so parse_signup() raises one SignupError with all of them, and the run printed the three in field order.
- The way out that was not taken. Only a body with no problems reaches the last line, which returns a frozen Signup with the email in lower case, as the first body of the run did.
- A body that is not an object. The third body, [1, 2], is a list. The shape check refuses it at once with one problem, "the body must be a JSON object", and no field is read.
Checking against what is allowed, rather than hunting for what is dangerous, is the core idea. A rule such as “3 to 20 letters, digits or underscores” accepts a known set and refuses everything else, including inputs nobody thought of. A rule such as “no quotes or angle brackets” fails on the first trick it was not written for, and it refuses honest users along the way.
Validate once, at the edge of the program
Text description of the diagram
The diagram is a flow from top to bottom.
- It starts with data from outside the program: JSON bodies, form fields, command-line arguments, rows of files and environment variables.
- All of it goes through one validator at the edge, parse_signup(data), which checks every field against what is allowed and collects every problem.
- If there is any problem, the data is refused with one error that lists every problem, and that error goes back to the sender.
- If every field is valid, the validator returns a typed, frozen Signup object that holds only values that passed.
- The rest of the program receives only that object: it trusts the types, checks nothing again, and the type checker follows the values from there.
Key idea
Validate once, where the data enters: check every field against what is allowed, report every problem together, and hand the rest of the program a typed, frozen object.
Checkpoint
Check: One validator at the edge
Every way of writing the checks ends in a typed object
The three ways, side by side:
| Approach | What it gives you | Best fit |
|---|---|---|
| A hand-written function | No dependency; the rules and the error report are whatever you write | A few fields at one edge |
A dataclass with __post_init__ |
No dependency; every object is checked however it is built | Domain objects that must always be valid |
| A pydantic model | Rules taken from type hints and Field() limits, a report with a code for every problem, and conversions in lax mode |
Many models, nested data and JSON APIs |
Each of the three ends with the same thing: a typed object that the rest of the program trusts. A hand-written function costs nothing but code; a dataclass keeps the rules next to the fields, so every object is checked however it is built; and pydantic reads the rules from the annotations themselves, which pays off once there are many models. Whichever you choose, write tests with bad input for every rule, because a validator that was only ever run on good data has not been tested at all.
Checkpoint
Check: Three ways to write the checks
Check yourself
The session asks first about the idea you have shown least, and every answer comes with its reason.
Practice that adapts to you
Practise this lesson
9 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Validators that hold up: __post_init__, the Python surprises and allowlists, with failure drills Pro: opens with a Pro, Premium or Ultimate pass
A validator is only as good as the inputs it was tested with. This part puts the checks inside a frozen dataclass, then runs a drill of inputs that Python accepts more willingly than most validators expect, and fixes each one.
In this part:
- Post-init validation in a frozen dataclass
- The validation drill: inputs Python accepts too willingly
- Exercise: a payout registration
- Validate a payout registration and report every problem
- Results of the sample tests
1 diagram · 3 runnable examples (Python) · 1 exercise with sample tests · about 8 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
pydantic at the edge: the same model, its error report, strict mode and the cost, measured Pro: opens with a Pro, Premium or Ultimate pass
pydantic turns type hints into validation code, which removes a lot of hand-written checks and adds a dependency with its own rules. This part writes the sign-up rules as a model, reads its error report, reproduces what lax mode converts, validates plain types and TypedDicts, and measures the cost against hand-written code.
In this part:
- The sign-up rules as a model, and its error report
- Validating plain types and TypedDicts with TypeAdapter
- What validation costs, measured
3 runnable examples (Python) · about 11 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
-
Fresher to mid · Warm-up
Why do type hints not protect a program from bad input, and what does?
Python never compares a value with its annotation. A function annotated age: int runs with the string "21", a dataclass declaring age: int stores whatever it is given, and a TypedDict is a plain dict at run time. Type checkers read the hints before the program runs, so they check the program's own code, not the data that arrives while it runs. Input from outside, such as JSON bodies, form fields, files, command-line arguments and environment variables, must be checked by code that runs: an explicit validator, checks in a dataclass's __post_init__, or a library such as pydantic that generates the checks from the hints. Validate once, where the data enters, and return typed objects that the rest of the program can trust.
-
Mid to senior
Design the validation layer of a public sign-up API that receives JSON with a username, an email, an age and a referral code. What do you check, where in the code, what do you return to the client, and what do you hand to the rest of the service?
-
Fresher to senior
Your validator accepted {"age": true} and stored an age of 1. Why did that happen, and which other inputs does Python accept more willingly than a validator expects? Give the checks you would write instead.
-
Mid to staff
A teammate wants to add pydantic to a service that today validates three endpoints by hand. When is that worth it, when is a hand-written validator or a dataclass enough, and how would you decide with numbers rather than taste?
Key takeaways
- Annotations, TypedDicts and dataclasses check nothing at run time; outside data needs validation.
- Validate once, where the data enters: check every field, report every problem, return a typed, frozen object.
- Check the shape first, then every field against an allowlist, with exact types and length limits.
- pydantic generates the checks from type hints; hand-written checks remain the lightest option for a few fields.
References
- dataclasses: post-init processing (Python Software Foundation)
- dataclasses: frozen instances (Python Software Foundation)
- json: encoding and decoding JSON (non-standard NaN and Infinity) (Python Software Foundation)
- Built-in functions: int() and integer string conversion length limitation (Python Software Foundation)
- unicodedata.normalize() (Python Software Foundation)
- Unicode Standard Annex #15: Unicode Normalization Forms (Unicode Consortium)
- OWASP Input Validation Cheat Sheet (OWASP Foundation)
- Pydantic documentation: models (Pydantic)
- Pydantic documentation: strict mode (Pydantic)
- Pydantic documentation: conversion table (Pydantic)
- Pydantic documentation: type adapter (Pydantic)
- Pydantic documentation: performance tips (Pydantic)
- RBI: frequently asked questions on NEFT (IFSC) (Reserve Bank of India)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress