Index Ai Academy
Phase 7 · Software engineering · Writing good code

Types and data modeling

Type hints, dataclasses and checking data at the edges of a program.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Data arrives from everywhere: CSV exports, web forms, APIs, and model outputs that are almost JSON. Much of it is subtly wrong, like a price as text or a missing email. Types let you state what your data should look like. Checking at the edges of your program means the rest of the code can trust it. This is exactly how good LLM apps handle structured output.

What you'll be able to do

  • Add type hints to functions and explain what does and doesn't check them
  • Model records with dataclasses and fixed choices with enums
  • Handle values that might be missing (None) on purpose
  • Turn untrusted input into checked, typed objects at the edge of a program

What it covers

  1. Types and type hints
  2. Dataclasses: records with named, typed fields
  3. None, on purpose
  4. A parser at the edge
  5. What type hints do and don't do
  6. Adding types to old code

Types and type hints

You can add two prices. You can't sensibly add a price and a street name. Every value has a type: int, float, str, list and so on. The type decides which operations make sense. 19.5 * 2 is 39.0. "19.5" * 2 is "19.519.5". Many bugs are a value of the wrong type arriving somewhere.

Type hints let you write down the types a function expects and returns:

def line_total(unit_price: float, qty: int) -> float:
    return unit_price * qty

print(line_total(19.5, 2))
print(line_total.__annotations__)
print(line_total("19.5", 2))     # Python still runs this!
It runs right here in your browser, with nothing to install.

The last line shows the catch: Python itself doesn't enforce hints. They're signs, not locks. They're notes for people and for tools. A type checker such as mypy or pyright reads your code without running it and reports the mismatch: "str" is not compatible with "float". Editors use hints to autocomplete and to underline mistakes as you type. AI coding tools read them as well.

Common hint shapes:

names: list[str]                 # a list of strings
prices: dict[str, float]         # str keys, float values
point: tuple[int, int]           # exactly two ints
email: str | None                # a string, or None if missing
def handler(event: dict) -> None: ...   # returns nothing
Quick check
You call def area(w: float, h: float) -> float as area("3", 4). What happens when the code runs?
  1. Python runs it anyway; a type checker would have flagged it before running
  2. Python raises a TypeError because of the hint
  3. Python converts "3" to 3.0 automatically
  4. The hint makes the function return None
Show the answer

Python runs it anyway; a type checker would have flagged it before running

Hints aren't checked at run time. "3" * 4 is "3333", a silent bug that mypy or pyright would catch.

Dataclasses: records with named, typed fields

A dict like {"sku": "TENT-2", "qty": 2} is a blank sheet of paper: easy to write on, and nothing stops a typo like order["qyt"]. A dataclass is a printed form. It declares the fields once, with types, and Python writes the boring parts of the class for you: __init__, a readable repr, and ==.

from dataclasses import dataclass, field
from enum import Enum

class Status(Enum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"

@dataclass
class Item:
    sku: str
    qty: int
    unit_price: float

@dataclass
class Order:
    id: str
    items: list[Item] = field(default_factory=list)   # a fresh list per order
    status: Status = Status.PENDING

order = Order("ord_1", [Item("TENT-2", 1, 249.0), Item("MUG-1", 2, 12.5)])
print(order.status, order.status.value)
print(order.items[1])
print(sum(i.qty * i.unit_price for i in order.items))
print(Status("paid") is Status.PAID)

An enum is the multiple-choice box on that form. It lists the only allowed values for something, like an order's status. Status("paid") turns text into the enum and raises ValueError for anything not on the list. So "payed" gets caught instead of quietly flowing through your system.

Add @dataclass(frozen=True) to make objects that can't change after they're created. Frozen objects are safe to share and can be dict keys.

Try it now

One small class gives you named fields, a readable print and a default, with no extra code.

Quick check
A dataclass writes __init__ and __repr__ for you. What does this print?
from dataclasses import dataclass

@dataclass
class Item:
    sku: str
    qty: int = 1

print(Item("TENT-2"))
  1. TENT-2
  2. Item(TENT-2, 1)
  3. Item(sku='TENT-2', qty=1)
  4. <__main__.Item object>
Show the answer

Item(sku='TENT-2', qty=1)

The generated __repr__ shows each field by name, as Item(sku='TENT-2', qty=1). qty took its default, 1.

None, on purpose

"No value" is None, like a box left empty on a form. It's the source of a famous class of crash: 'NoneType' object has no attribute .... The fix is to be explicit. Hint it as str | None, and handle the None case right where the value is used:

def greeting(name: str | None) -> str:
    if name is None:
        return "Hi there"
    return f"Hi {name.strip().title()}"

print(greeting(None), "|", greeting("  ada  "))

A type checker then warns you wherever you forget the check.

Parse, don't validate

Here's the most useful idea in this lesson. An airport checks your passport once, at the border. After that, nobody inside asks whether you're allowed to be there.

Untrusted data enters at the edges of your program: a request body, a CSV row, a model's JSON. At that edge, convert it into your typed objects once. Check everything and fail loudly with a clear message. Then the inside of your program only ever sees clean Order objects, and never has to wonder whether qty is a string.

This is called parse, don't validate. A validator answers "is this OK?" and hands back the same messy dict. A parser hands back a new, trustworthy object, or refuses.

In plain words: check once at the door, and turn what comes in into something the rest of your code can trust.

Worked example

A parser at the edge

A model was asked for JSON about an item. Its reply is close, but the numbers are strings. The parser converts what it safely can and rejects what it can't.

import json
from dataclasses import dataclass

@dataclass(frozen=True)
class Item:
    sku: str
    qty: int
    unit_price: float

def parse_item(raw: dict) -> Item:
    try:
        sku = str(raw["sku"]).strip()
        qty = int(raw["qty"])
        unit_price = float(raw["unit_price"])
    except KeyError as missing:
        raise ValueError(f"item is missing {missing}") from None
    except (TypeError, ValueError):
        raise ValueError(f"item has a bad number: {raw}") from None
    if not sku:
        raise ValueError("item sku is empty")
    if qty < 1:
        raise ValueError(f"item qty must be at least 1, got {qty}")
    return Item(sku, qty, unit_price)

model_reply = '{"sku": "TENT-2", "qty": "2", "unit_price": "249.00"}'
print(parse_item(json.loads(model_reply)))
for bad in [{"sku": "MUG", "qty": 0, "unit_price": 3}, {"sku": "MUG", "unit_price": 3}]:
    try:
        parse_item(bad)
    except ValueError as err:
        print("rejected:", err)
In the lesson you can change this and run it.

The good reply becomes a real Item with an int and a float. The bad ones are rejected with messages that name the problem. Code after this point never checks types again. Libraries such as Pydantic build this pattern from type hints alone, and many API frameworks use them for every request body.

Quick check
Where in a program is the best place to check and convert data from an API?
  1. Only when an error happens
  2. Once, where the data enters, turning it into typed objects
  3. In every function that uses the data
  4. Nowhere: trust the API's documentation
Show the answer

Once, where the data enters, turning it into typed objects

Parse at the edge, once. Everything inside can then rely on the types.

What type hints do and don't do

Here's a surprise for most people: Python ignores type hints when it runs your code.

def line_total(price: float, qty: int) -> float:
    return price * qty

print(line_total("ab", 3))

This runs without complaint and prints ababab, which is nonsense. Hints are checked by a separate tool, such as mypy or pyright, before the code runs. In your editor they catch the mistake as you type. In production they do nothing, so they can't protect you from bad data arriving at run time. That needs validation, which is where parsing comes in.

Why raw dictionaries spread problems

Suppose an API gives you a dictionary, and you pass it deep into your program. Every function now has to wonder: is "total" there? Is it a number or text? Soon you have the same checks everywhere, some of them missing, and a misspelled key that only fails at 2 a.m.

The fix is to convert once, at the edge of your program, into a dataclass. If the conversion succeeds, everything further in can trust what it holds, and your tools can check every use of it.

Make wrong choices impossible

Plain strings are easy to mistype. status = "shiped" raises no error and quietly never matches anything. An Enum limits the choices, so a typo fails at once.

None deserves the same care. When a value might be missing, say so in the type, as str | None. Then the checker makes you handle that case where it matters, not ten calls later, where the crash shows up far from its cause.

Adding types to old code

You don't need to do it all at once, any more than you'd label a whole garage in one afternoon. Start with the functions other code calls most, and the ones that have caused bugs. Run the checker in CI so that new code stays clean, and let the old code catch up as you touch it.

A good sign you've gone wrong is Any everywhere, since Any switches checking off. Another is a type so complicated nobody can read it. When that happens, a small dataclass usually says the same thing more clearly. Types are there to help the next reader, so keep them readable, and remove any that no longer tell the truth.

Practice what you just read

  • 6 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

What checks type hints?

  1. A type checker such as mypy or pyright, before the code runs
  2. Python itself, every time the function is called
  3. The operating system, as the program is loaded
  4. Nothing at all: hints are only decoration for people

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson