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.
-
Functions That Wrap Functions
-
Python Closures: Functions That Remember
-
Python Decorators in Real Code: Timing, Retry and Logging
-
-
-
Data That Doesn't Fit in Memory
-
Python Iterators: What a for Loop Actually Does
-
Python Generators and yield: One Piece at a Time
-
Python Context Managers: Clean Up Even When Things Fail
-
-
-
Catch Bugs Before You Run the Code
-
Python Type Hints Beyond the Basics: Optional, Union, Generics
-
Checking Types With mypy in a Real Project
-
-
-
Doing More Than One Thing at a Time
-
Python Concurrency Basics: Threads, Processes or asyncio?
-
Python asyncio: Wait for Many Things Without Waiting Longer
-
-
-
Ship It, Then Make It Fast
-
Packaging a Python Project With pyproject.toml
-
Profiling Python Code: Measure Before You Optimize
-
-
- Final Check: Python Advanced
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
Anyeverywhere.Anyturns 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
Comments (0)
Sign in to commentNo comments yet.