Skip to Content
Python Advanced: Code That Scales Past Your Own Laptop
Advanced
0 likes
Courses
Python Advanced: Code That Scales Past Your Own Laptop

Python Type Hints Beyond the Basics: Optional, Union, Generics

Optional and union types, TypedDict, Protocol, and generic functions and classes. After this, you can describe what your functions really accept and return.

Course content

Sign in to save your progress.

Course Home

Basic type hints say "this is an int" or "this is a str". Real code is less tidy: a function that might return nothing, an argument that can be an ID or a whole object, a dictionary from an API with known keys, a function that works on "anything with a .read() method". This lesson covers the hints for those cases.

Skip ahead: If you already use X | None, TypedDict, Protocol and TypeVar (or the Python 3.12 generic syntax), continue to "Checking Types With mypy in a Real Project."

First, what hints do and don't do

def add_vat(amount: float) -> float:
    return amount * 1.12


print(add_vat("100"))
Traceback (most recent call last):
...
TypeError: can't multiply sequence by non-int of type 'float'

Python doesn't check hints when the code runs. The call went through, and the error came from the multiplication inside. Hints are for people reading the code, for editors, and for a type checker like mypy, which reads the code without running it and reports this mistake before anyone calls the function. That is the next lesson. The examples here use Python 3.10 or newer syntax.

Optional: a value or None

The most useful hint of all, because "forgot it could be None" is one of the most common Python bugs:

def find_email(customers: dict[int, str], customer_id: int) -> str | None:
    return customers.get(customer_id)


customers = {1: "ana@example.com"}
email = find_email(customers, 2)
if email is None:
    print("no email on file")
else:
    print(email.lower())
no email on file

str | None tells the caller to check before using it. You will also see Optional[str] from typing in older code. It means exactly the same thing.

⚠ Warning: Don't write -> str on a function that can return None just to keep the hint short. A wrong hint is worse than none: every caller trusts it, skips the check, and crashes with AttributeError: 'NoneType' object has no attribute ... in production.

Union: one of several types

from datetime import date


def parse_date(value: str | date) -> date:
    if isinstance(value, date):
        return value
    return date.fromisoformat(value)


print(parse_date("2026-10-13"))
print(parse_date(date(2026, 1, 1)))
2026-10-13
2026-01-01

Inside the if isinstance branch, a type checker knows value is a date. After it, it knows it must be a str. This is called narrowing, and it is how unions stay safe to use. If a union has more than two or three types, it is often a sign the function is doing too many jobs.

TypedDict: dictionaries with known keys

JSON from an API arrives as a dictionary. dict[str, Any] says nothing useful. A TypedDict lists the keys and their types, and the data stays a normal dictionary at runtime:

from typing import NotRequired, TypedDict


class Order(TypedDict):
    id: int
    customer: str
    amount: float
    coupon: NotRequired[str]


def order_label(order: Order) -> str:
    label = f"#{order['id']} {order['customer']}: {order['amount']:.2f}"
    if "coupon" in order:
        label += f" (coupon {order['coupon']})"
    return label


print(order_label({"id": 7, "customer": "Ana", "amount": 1200}))
print(order_label({"id": 8, "customer": "Ben", "amount": 450, "coupon": "OCT10"}))
#7 Ana: 1200.00
#8 Ben: 450.00 (coupon OCT10)

A checker now catches order["custmer"] and a missing amount. NotRequired needs Python 3.11 or newer (or the typing_extensions package).

Protocol: "anything that has this method"

Sometimes you don't care what the class is, only what it can do. A Protocol describes that, and any class with matching methods fits, without inheriting from anything:

from typing import Protocol


class Notifier(Protocol):
    def send(self, to: str, message: str) -> bool: ...


class EmailNotifier:
    def send(self, to: str, message: str) -> bool:
        print(f"email to {to}: {message}")
        return True


class FakeNotifier:
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, to: str, message: str) -> bool:
        self.sent.append((to, message))
        return True


def remind(notifier: Notifier, to: str) -> None:
    notifier.send(to, "Your invoice is due tomorrow.")


remind(EmailNotifier(), "ana@example.com")
fake = FakeNotifier()
remind(fake, "ben@example.com")
print(fake.sent)
email to ana@example.com: Your invoice is due tomorrow.
[('ben@example.com', 'Your invoice is due tomorrow.')]

Neither class mentions Notifier. They fit because they have a matching send. The fake one is handy in tests, where you don't want real emails going out.

Generics: keep the type that came in

A function that returns the first item of a list should return the same type the list holds. Without generics, you would write -> object and lose that information:

from typing import TypeVar

T = TypeVar("T")


def first_or_default(items: list[T], default: T) -> T:
    return items[0] if items else default


name = first_or_default(["Ana", "Ben"], "nobody")
count = first_or_default([], 0)
print(name.upper(), count + 1)
ANA 1

A checker knows name is a str and count is an int. On Python 3.12 and newer, you can skip the TypeVar line and write def first_or_default[T](items: list[T], default: T) -> T:. Same meaning, shorter.

Accept broad, return specific

For arguments, ask for the least you need. For return values, promise exactly what you give:

from collections.abc import Iterable


def total(amounts: Iterable[float]) -> float:
    return sum(amounts)


print(total([100.0, 250.5]))
print(total((10.0, 20.0)))
print(total(x * 2 for x in [1.0, 2.0]))
350.5
30.0
6.0

list[float] would reject the tuple and the generator, even though the function only loops once. Iterable says what the function really needs.

Common mistakes

  • Any everywhere. Any turns checking off for that value. Use it where you truly can't know the type, not to make errors go away.
  • Hints that lie. Covered in the warning above. A wrong hint is worse than none.
  • Hinting every local variable. Type checkers infer most of them. Hint function arguments and return values; that is where the value is.

💡 Tip: Start with the functions other people call: the public functions of a module. Their hints work as documentation that can't go out of date, because the checker complains when they are wrong.

What happens if you skip this step

Your hints stay at int and str, and the interesting cases, the ones that actually cause bugs, are left as Any or not hinted at all. The type checker then has nothing to check exactly where it would help most: the None nobody expected and the dictionary key someone misspelled.

Look at a function in your code that sometimes returns None. How many of its callers check for it today?

About

Optional and union types, TypedDict, Protocol, and generic functions and classes. After this, you can describe what your functions really accept and return.

Duration
10 min
Last update
09/01/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.