Python, the Right Way — Data Classes, Decorators & Pydantic
A “refresher” on paper, a foundation in practice. The instructor quietly took an AI agent apart into its plain-Python pieces — so the framework weeks later feel like nothing more than imports.
What this class was really about
On the surface, a Python refresher. Underneath, the instructor was disassembling an AI agent into its plain-Python parts, so that when LangChain/LangGraph arrive in a few weeks, “nothing feels like magic.”
An agent is not exotic. It is a class holding some state, calling functions (some wrapped by decorators), talking to services over APIs that return JSON, guarded by error handling, with its inputs and outputs locked down by Pydantic. Master those five ideas in vanilla Python and every framework later is “just imports.” The two-week goal: build an agent (LLM + tools + memory) using only Python, no framework.
Where this sits in the course
The instructor teaches from first principles — concepts before frameworks. This is Phase 0, deliberately placed before agents.
| Now (Day 2) | Next class | ~By class 12 | After |
|---|---|---|---|
| Python foundation: classes, decorators, errors, types | AI basics (LLM, tokens, context window) + Pydantic data validation + a real AI call | An agent (LLM + tools + memory) in plain Python, no framework | LangChain / LangGraph / CrewAI, then real projects, RAG, deploy & agent-ops |
Coloured callouts carry meaning even in grayscale (each is labelled): Definition, Core idea, Rule, Instructor insight, Tip, Watch out, Example, Extra context. Code blocks are marked ● exact or ◐ reconstructed — the instructor typed while talking, so recovered code is a clean, runnable version of what was narrated, not a verbatim capture.
A Linux-style terminal on Windows
Several students hit errors on Windows. The instructor booted a fresh Windows install running as a VM on top of their Mac to reproduce the exact problem live — and used that VM to teach the “environment / container” idea in passing.
The container / environment intuition
Windows ran as a container on an environment inside macOS. Nothing on the Mac was available inside it — it started blank. The recurring question that motivates reproducible environments: if I set up the exact versions here and hand them to you, won't it just work on your machine too?
Three ways to type commands on Windows
| Terminal | What it is | Runs shell / Linux commands? |
|---|---|---|
CMD | Classic Command Prompt | No — sh is “not recognized” |
PowerShell | Newer Windows shell | Mostly no (doesn't recognise sh) |
| Git Bash | Linux-style shell shipped with Git for Windows | Yes — use this |
“99% of the time development happens on a Linux/Unix server. You will not find a developer who can't run a Linux command.” If you can't run shell commands locally, you're helpless the moment you touch a cloud box — so this is table stakes, not polish.
The fix, step by step
- Install VS Code and Git for Windows (Git Bash). During Git's installer, accept “add a Git Bash profile to Windows Terminal” and enable Git from the command line.
- Latest Windows 11 already ships Windows Terminal — you don't install it. Confirm your build with
winver. - Open Git Bash; now the standard install commands work.
- In VS Code, open the terminal and pick the Git Bash profile — never PowerShell. “95% of what I do is in the terminal.”
winver # check Windows build/version
curl -LsSf https://astral.sh/uv/install.sh | sh # install uv — GIT BASH, not CMD/PowerShell
uv --version
uv python install # let uv manage a Python interpreter
uv init my_first_project # scaffold main.py, pyproject.toml, .python-version
cd my_first_project
uv add requests # add a dependency (+ writes uv.lock)
uv pip install -r requirements.txt
source .venv/Scripts/activate # activate venv (Windows / Git Bash)
# source .venv/bin/activate # activate venv (macOS / Linux)
uv run main.py # run a file inside the managed env
- Typing
pythonon fresh Windows opens the Microsoft Store (an app-execution alias stub) because no interpreter is installed. Lettinguvmanage Python removes the surprise. - The
uvinstall script usessh— run it in Git Bash, or it fails in CMD/PowerShell. - Activation path differs by OS:
.venv/Scripts/activateon Windows vs.venv/bin/activateon macOS/Linux. (The instructor hit this live and corrected it.)
On the VM, uv pulled Python 3.14; the instructor's Mac was on 3.13. Same commands, reproducible setup — that difference is exactly why pinning versions and shipping a lockfile matters.
uv ties together so your machine and a teammate's produce the same install.You've got it if a new terminal opens a Git Bash prompt, uv --version works, and uv run main.py executes. If not, reinstall Git Bash + VS Code and reopen the terminal. Rule going forward: never open PowerShell; always Git Bash.
Python & JSON refresher
A fast pass over the primitives, run in a notebook so outputs are visible. “I'm not teaching, I'm refreshing” — but the parts that bite people later were flagged deliberately.
Variables, types & f-strings
city = "Tokyo"
temperature = 21.5
is_raining = False
print(type(city)) # <class 'str'>
print(type(temperature)) # <class 'float'>
print(type(is_raining)) # <class 'bool'>
print(f"It is {temperature}°C in {city}.") # f-string interpolation
Lists & negative indexing
cities = ["Tokyo", "Paris", "London"]
cities.append("Berlin")
print(cities[0]) # Tokyo (first)
print(cities[-1]) # Berlin (last)
mixed = [1, "two", 3.0, True] # a list can hold mixed types — no error
Precisely: Python does ship an array module (from array import array) for single-type arrays, and NumPy is the real-world choice for numeric work. The aside that Python “is transformed to C” refers loosely to CPython, the reference interpreter written in C — your Python isn't compiled to C source. For this course, lists are all you need.
JSON is a dictionary (for this course)
JSON is how information travels between services, and it's just key–value pairs. Values can themselves be lists or nested dicts.
JSON = a text format of key–value pairs for exchanging data. In this course, treat dict == JSON: a Python dictionary is the in-memory shape of the same thing.
person = {
"name": "Alice", # key : value
"age": 25,
"skills": ["Python", "AI", "SQL"], # a LIST of values
"address": {"city": "Mumbai", "pin": "400001"} # a nested dict / JSON
}
Reading values safely: .get() vs []
Real APIs return JSON, and the key you want may be missing. Demoed against the free Cat Facts API (returns {"fact": …, "length": …}).
reading = {"city": "Tokyo", "temp": 21.5}
print(reading["humidity"]) # 💥 KeyError — crashes
print(reading.get("humidity")) # None — safe, no crash
for key, value in reading.items():
print(key, "->", value)
Prefer dict.get("key") when a key might be absent — it returns None instead of raising KeyError. This is exactly the kind of defensive read you want around unpredictable API/AI responses.
The dict == JSON shortcut is fine for the course. Precisely: JSON is text (always double-quoted keys); a dict is a live object. You cross between them with json.dumps() / json.loads(). In your own Python, single or double quotes both work; a JSON document requires double quotes.
Classes & OOP
Why learn OOP if AI writes the code? Because you must read what it produces — and every library, and the agent you'll build, is “just classes.” Visualised live in Python Tutor.
You wouldn't create a hundred accounts as bank_account_1 = 123, bank_account_2 = 234… Instead you define one blueprint (the class) and stamp out as many objects as you like.
OOP = Object-Oriented Programming. A class is a blueprint; an object is a concrete instance stamped from it, carrying its own data (attributes) and behaviour (methods).
class BankAccount:
def __init__(self, owner: str, balance: float = 0):
self.owner = owner # set up THIS object's state
self.balance = balance # default 0 unless passed
self.history = []
def deposit(self, amount):
self.balance += amount
self.history.append(f"Deposited {amount}")
def withdraw(self, amount):
if amount > self.balance: # guardrail: can't overdraw
print("Insufficient funds"); return
self.balance -= amount
self.history.append(f"Withdrew {amount}")
def show_history(self):
for entry in self.history:
print(entry)
acc1 = BankAccount("Mayank", 1000)
acc1.deposit(200) # balance → 1200
acc1.withdraw(50) # balance → 1150
Constructor
Runs once when the object is created; initialises attributes. Without a class you'd have no clean way to set up and carry this state.
self
The current object — lets each method read/update that specific account's data.
Data vs behaviour
balance is an attribute; deposit() is a method. Access with a dot: acc1.balance.
Why it pays
“You get paid when you can go inside and learn why something is failing” — reading a framework's class Agent is this exact skill.
self the same as Java's this?self as the current object a method operates on; learn it properly because it appears everywhere in class-based code (including agents).Step through object creation in Python Tutor: you literally watch execution enter __init__, set owner/balance, then return a finished object. Seeing the pointer move makes “an object is a filled-in copy of the blueprint” click faster than prose.
Data classes
A @dataclass writes the boilerplate — __init__, __repr__, __eq__ — straight from type-hinted attributes. The Java-crowd shorthand: “it's like Lombok.”
Boilerplate = repetitive setup code you'd otherwise write every time (like boiling water before cooking). Developers are lazy about already-solved problems, so they build tools to skip it. For a data-holding class, that tool is @dataclass.
from dataclasses import dataclass, field
@dataclass
class Book:
title: str
author: str
pages_read: int = 0
tags: list = field(default_factory=list) # see the bug below
def read(self, pages):
self.pages_read += pages
def add_tag(self, tag):
self.tags.append(tag)
my_book = Book("Atomic Habits", "James Clear")
# No __init__ written by hand — the decorator generated it.
@dataclass is itself a decorator (Topic 6). It inspects the type-hinted attributes and auto-generates the constructor + comparison methods — you declare what data a Book holds and Python writes the plumbing. It's mainly for avoiding __init__; when you need full behaviour and getters/setters, write a normal class.
The line that confuses everyone: field(default_factory=list)
Why not just tags = []? Because a bare [] default is created once and secretly shared by every Book. Add a tag to one, it appears on all.
default_factory builds a new empty list per object.“This is exactly the bug you'll never catch when you vibe-code and don't know the concept.” AI may hand you tags = []; it runs, then two objects mysteriously share tags in production. field(default_factory=list) means “make a new empty list every time a Book is created.”
Later, instead of Book you'll define things like agent state and messages with this exact pattern. LangGraph and many custom agent frameworks represent structured state as data classes (or similar models) precisely because they keep code clean and easy to maintain.
Error handling
When AI writes code, the first version usually throws errors. And when your code hits an API or an AI, the call will sometimes fail. The question posed: should your whole agent die because one call failed? No — so you wrap the risky bit.
| Kind of error | Example | Meaning |
|---|---|---|
ZeroDivisionError | 10 / 0 | Runtime error — the operation is impossible |
| Syntax error | prnt(x | Code written/spelled wrong — won't run at all |
| Logic error | “numbers just look wrong” | Runs fine, gives the wrong answer |
def safe_divide(a, b):
try:
result = a / b # the risky line
except ZeroDivisionError:
print("Can't divide by zero — using 0 instead")
result = 0
finally:
print("Done attempting the division") # ALWAYS runs
return result
>>> safe_divide(10,0) → “Can't divide…” · “Done attempting…” · 0
try
Attempt the line that might blow up — an API hit, reading a key, parsing input.
except
Catch the failure and recover instead of crashing the program (or the agent).
finally
Always runs, error or not. Classic use: close a database connection, or reset a value after a timeout.
Reading an API key, making an API call, and making an AI call all belong inside try/except. A function written this way “will never fail” — it always returns something sane, which keeps an agent alive when a provider hiccups.
Python uses try / except and you raise an error (Java uses try / catch and throw). If you don't know the specific error type, catch a broad Exception — common failures here are connection/timeout errors.
Decorators
The analogy ran all session: you hand me a gift; I wrap it; the contents don't change, but what you get back is more than the plain box.
A decorator is a function that takes another function and returns a new, enhanced function — without changing the original's code.
@announce takes greet, returns a wrapper that does extra work before/after calling the original. You now call the wrapper.import functools
def announce(func): # takes a function…
@functools.wraps(func) # keep func's real __name__, __doc__
def wrapper(*args, **kwargs):
print(f"Starting {func.__name__}...")
result = func(*args, **kwargs) # run the original
print(f"Finished {func.__name__}.")
return result
return wrapper # …returns an enhanced function
@announce
def greet(name):
return f"Hello {name}"
print(greet("ma'am"))
Pass anything through
*args = positional args; **kwargs = keyword args (a dict of key–value pairs). The wrapper forwards them so it works for any signature.
Reuse
The nested wrapper exists so the decorator can be applied to any function, again and again.
None came fromIf your wrapped function has no return, printing its result shows None — “a function that returns nothing gives you None.” That's why wrapper ends with return result.
The use case that makes it click: timing
import time, functools
def timed(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.4f}s")
return result
return wrapper
@timed
def slow_addition(a, b):
time.sleep(1); return a + b
This is the mechanism behind agent tools. When you later write @tool above a function, a decorator is attaching the metadata (name, description, signature via func.__name__) the agent needs to discover and call it. Stacking is allowed too — with @timed above @announce, the order given was “first timed, then announced” (topmost = outermost wrapper).
Calling an API — and the “fake AI”
The big de-mystification: talking to ChatGPT/Claude is the same shape as calling any web API. Send a request in the format the service wants; get JSON back. A fake AI was built in between so the leap from “API” to “AI” feels small.
A real API with requests
Using the free Frankfurter currency API (real ECB rates). The response comes back as JSON — which you already know is a dict.
import requests
def convert_currency(amount, from_ccy, to_ccy):
url = f"https://api.frankfurter.app/latest?from={from_ccy}&to={to_ccy}"
try: # this can fail → wrap it
response = requests.get(url)
data = response.json() # APIs return JSON → a dict
return amount * data["rates"][to_ccy]
except Exception as e:
print("API call failed:", e); return None
“All your APIs normally return you a JSON.” So the single most reusable skill for working with any service — including AI — is being fluent with dictionaries. Read the API's docs to learn the exact request shape (headers, body, JSON vs XML) it expects.
The “fake AI”: playing the role of ChatGPT
Rather than jump straight to a paid AI call, the instructor faked one — a function that plays the role of AI, taking the same model, max_tokens, messages inputs and returning a message.
def fake_ai(model, max_tokens, messages):
user_message = messages[-1]["content"].lower()
if "currency" in user_message or "convert" in user_message:
reply = "I'd use a currency-conversion tool instead of guessing."
elif "weather" in user_message:
reply = "I'd use the weather tool."
else:
reply = "Ask me about currency or weather."
return {"role": "assistant", "content": reply} # a dict/JSON
for q in ["Convert $50 to Euro"]:
response = fake_ai("gpt-x", 100, [{"role": "user", "content": q}])
print(response["content"])
“I don't care what API is present — real or fake. I give it the request the way it wants, I get the response.” The real client.chat.completions.create(model=…, messages=[…]) call you'll use later has this exact role/content shape. The next class swaps the fake AI for a real one.
The from __future__ import annotations line the instructor pasted is a best-practice detail (defers evaluating type hints) — treated as boilerplate you can look up, not something to master now.
Pydantic
Python is dynamically typed — it will happily let a name be a number and an age be a string. Fine for a script, dangerous when data flows into a database, an API, or an agent.
Dynamically typed: a variable's type can change at runtime and Python doesn't enforce type hints. Pydantic is the library that lets you define the shape of your data (via BaseModel) and enforce it.
The problem: type hints don't enforce anything
def create_user(name: str, age: int):
return {"name": name, "age": age}
create_user("Mayank", "fifteen") # runs fine — Python does NOT enforce the hint!
You could hand-write isinstance(...) checks — but you'd repeat them in create_user, update_user, delete_user… That doesn't scale.
The fix: a BaseModel
from pydantic import BaseModel
class User(BaseModel): # inherit BaseModel — no __init__ needed
name: str
age: int
u1 = User(name="Mayank", age=25) # ✓ correct types
u2 = User(name="Mayank", age="25") # ✓ Pydantic COERCES "25" → 25
u3 = User(name="Mayank", age=-5) # ✓ -5 IS a valid int (see note!)
u4 = User(name="Rahul", age=[1,2,3]) # ✗ ValidationError: not an int
ValidationError before bad data can spread.- Coercion:
age="25"works because Pydantic type-casts the string to25(its default lax mode). A list can't become an int → error. age=-5passes because −5 is a valid integer. Rejecting “age must be > 0” is data validation (rules on values), which needs field constraints/validators — next class. So far you've enforced type validation only.
1. Pydantic creates rigid data models for inputs and outputs, making code more error-safe and agents/software better.
2. It enforces type (today) and data validation (next class).
3. It's what FastAPI is built on — when you expose your agents via API servers, Pydantic is already there. Everyone downstream assumes you know it.
Curated Q&A
From a long live Q&A, kept only where the answer teaches something reusable. Reveal each answer when you want it.
FrameworksIf I can build agents in plain Python, why use LangChain / LangGraph / CrewAI?▶
They're far more mature and take heavy lifting off you — like SMS vs WhatsApp. But understand the fundamentals first (it's just text in, text out) so a framework isn't a black box. Frameworks also tend to use more tokens — a real trade-off.
FrameworksNode.js or LangGraph — which path?▶
Category error: Node.js is a runtime/language; LangGraph is a framework. LangChain/LangGraph support TypeScript too, so you can use them from Node. Language doesn't matter much; the concepts do.
ProductionShould I use agents everywhere? What about token cost?▶
The instructor avoids agents where simple orchestration or a direct API call will do — agents burn more tokens and you lose control over what runs inside. Ask: does this genuinely need an agent? “Simple designs scale.”
ProductionOpen-source models or frontier (OpenAI/Claude)?▶
For client work, only frontier models shipped so far — they're battle-tested, and the infra cost of hosting/testing open models often outweighs the API cost. Clients rarely fund a $200 machine to trial many open models before a POC. Inside an org with its own hardware and strict data rules, the calculus can flip.
ProductionEmbedding-based RAG or non-embedding?▶
Depends on data + domain knowledge, decided after experimentation — not a fixed rule. Example: two projects, same problem; one used embeddings (large docs), the other a structured non-embedding approach (small warranty docs with a strong findable structure). Domain knowledge drives the system-design call.
CareerJob posts say “5+ years AI experience” — but AI is newer than that?▶
AI engineering = AI + software development. Those postings want strong SWE experience plus the ability to integrate AI — not literally N years of LLM work. For most product roles you're a consumer of models; transformer internals matter mostly at senior levels and for interviews (like DSA). Know ~60–70% of a JD comfortably.
ProductionCopilot gave me and a colleague different answers to the same prompt. Why?▶
Two reasons: (1) AI output is fuzzy / non-deterministic; (2) tools silently keep memory/context (workspace history, agent.md/claude.md instruction files) that differs between a long session and a fresh checkout. Fix with more explicit instructions, not just new sessions.
ConceptsWhy Pydantic instead of my own if checks?▶
Scaling. You have many functions (create/update/delete_user). Hand-rolled checks get copied into all of them and changing a rule means editing every copy. Define the model once; validation rides along.
Reusable advice from the room
- Simple designs scale. Reach for a plain API call + orchestration before an agent — more control, fewer tokens, still scales.
- Read the code AI writes. “When it breaks, you're just sitting with AI.” The paid skill is going inside to see why it failed — which is why classes, decorators and types are non-negotiable.
- AI is ~15–20% of a project. The other 80% is ordinary software and data work. The same problem can need embeddings or not, depending on data + domain knowledge.
- Frontier models are usually the pragmatic client choice — battle-tested; self-hosting open models often costs more in infra/testing than it saves.
- Better instructions > new sessions. AI is fuzzy and tools carry hidden context. Improve prompt specificity rather than blaming randomness.
- uv is mature (~1.5 years); using it and a clean venv is what you say in an interview to show you can accelerate real development.
- Interview lens. Transformer/DSA depth is asked to prove you can handle hard problems, even if daily work is mostly integration.
Corrections worth pinning up
Type hints don't enforce types
def f(age: int) still accepts "fifteen" — Python is dynamically typed. Enforcement needs Pydantic.
tags = [] as a default
Creates one shared list across all instances. Use field(default_factory=list).
obj["key"] vs obj.get("key")
Missing key: [] raises KeyError; .get() returns None.
Pydantic and negative age
-5 passes — it's a valid int. Value rules (age > 0) are data validation, taught next class.
Pydantic and string age
"25" is coerced to 25. Only truly incompatible types (a list where an int is needed) error out.
Decorators don't change the function
They wrap it; you call a new wrapper that adds behaviour around an untouched original.
None in output
If the wrapped function has no return, printing its result shows None. Add return result.
Not every Windows terminal works
CMD/PowerShell don't recognise sh. Use Git Bash.
Small exercises from this session
Learning exercises derived from what was taught (not transcript content). Run each in a notebook or with uv run; reveal a hint only after attempting.
Safe reader
Given {"city":"Tokyo","temp":21.5}, print the humidity without crashing, then loop and print every key/value.
Hint / check▶
.get("humidity") → None; then for k, v in d.items(): print(k, v).
Playlist
Make a @dataclass Playlist(name: str, songs) with songs defaulting to empty. Create two, add a song to one, confirm the other stays empty.
Hint / check▶
songs: list = field(default_factory=list). If you used = [], both would share the song — that's the bug to feel.
Bulletproof int
Write parse_age(x) returning int(x), or 0 if invalid, and always print “done”.
Hint / check▶
try: return int(x) / except ValueError: return 0 / finally: print("done").
Log-and-time
Write @timed on a function that sleeps 1s. Confirm it prints elapsed time and the real return value.
Hint / check▶
Remember return result inside wrapper, and @functools.wraps(func) to keep the name.
Currency call
Hit Frankfurter to convert 50 USD→EUR. Print the raw .json(), then pull the rate out of the dict.
Hint / check▶
requests.get(url).json() → data["rates"]["EUR"], inside try/except.
Break the model
Define User(BaseModel) with name: str, age: int. Predict which pass before running: age="30", age=-1, age=[1,2].
Hint / check▶
"30" → coerced to 30 (pass). -1 → valid int (pass). [1,2] → ValidationError.
Check yourself
Answer in your head (or explain aloud), then reveal.
1. Why can't you install uv from PowerShell the way the docs show?▶
The install script uses sh, which CMD/PowerShell don't recognise. Run it in Git Bash.
2. Explain dict.get("x") vs dict["x"] for a missing key.▶
.get() → None (no crash); [] → KeyError.
3. What breaks if a dataclass uses tags = [] as a default, and why?▶
All instances share one list — a tag added to one appears on all. The default is created once. Fix: field(default_factory=list).
4. Which of try/except/finally always runs, and give a real use.▶
finally — error or not. Classic use: closing a DB connection.
5. Define a decorator in one sentence, and say why the inner wrapper exists.▶
A function that takes a function and returns a new, enhanced one without changing the original. The nested wrapper exists so the decorator can be reused on any function.
6. What format does a web API return, and what Python type does it become?▶
JSON — which maps to a Python dict. An AI call has the same request/response shape.
7. Does User(name="Sam", age="25") fail for age: int? What about age=[1,2,3]?▶
"25" is coerced to 25 (passes). [1,2,3] raises a ValidationError.
8. Is enforcing “age must be positive” type or data validation?▶
Data validation (a rule on the value) — not covered yet; today was type validation.
9. Reconstruct from memory: what are the five Python pieces an agent is made of?▶
A class holding state · functions (some decorated) · API/AI calls returning JSON · error handling · Pydantic-validated inputs/outputs.
The whole session, compact
Session in 60 seconds
Get a Linux-style terminal (Git Bash) and uv so setups are reproducible. Refresh the primitives an agent is secretly built from: lists/dicts/JSON (APIs return JSON = dicts; use .get()), classes (blueprint + state), @dataclass (skip boilerplate; beware shared [] defaults), try/except/finally (wrap fragile calls), decorators (function → enhanced function; power agent tools), and API/AI calls (same request/response shape). Finish with Pydantic: dynamic typing is unsafe, so BaseModel enforces types (and, next class, data rules).
Core ideas
- An agent = class + state + functions + API calls + error handling + Pydantic validation. Nothing exotic.
- APIs (and AI) return JSON → fluency with dicts is the reusable skill.
- Type hints don't enforce; Pydantic does (coerces where it can, errors where it can't).
- Decorators wrap behaviour around an unchanged function — the mechanism behind agent tools.
Code patterns
data.get("k") # safe dict read → None if missing
tags: list = field(default_factory=list) # dataclass mutable default (never = [])
try: risky()
except Exception as e: recover(e)
finally: cleanup() # always runs (close DB)
@functools.wraps(func) # inside a decorator's wrapper
data = requests.get(url).json() # → dict
class User(BaseModel): name: str; age: int # typed, validated
Instructor takeaways
Learn the basics so frameworks aren't a black box — then LangChain is easy.
You're paid to know why code fails, not to run it. Go inside the library.
Simple designs scale; avoid agents unless the problem truly needs one (tokens + control).
AI is ~15–20% of a project; the rest is ordinary software and data work.
Recall prompts
Cover the notes and answer: Why Git Bash over PowerShell? · What does field(default_factory=list) prevent? · One sentence: what is a decorator? · Why does User(age="25") succeed but User(age=[1,2,3]) fail? · What's the difference between type and data validation?