Advanced Type Hints

Reviewed & published by Brayan K

Advanced type hints let you describe flexible, reusable, and precisely-shaped types — generics, structural protocols, and typed dictionaries — that static checkers verify before your code ever runs.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

These are the tools that make large Python codebases maintainable. The interpreter ignores them at runtime, but mypy and your editor turn them into a powerful safety net.

What You'll Learn in This Lesson

1 Optional, Union, and the | Syntax (and Why Hints Don't Run)

A value that could be one of several types is a Union. A value that could be a given type or None is Optional. Since Python 3.10 you can write both with a clean pipe:

from typing import Optional, Union

# These three signatures all mean the same thing:
def f(x: Optional[int]): ...        # int or None
def g(x: Union[int, None]): ...     # int or None
def h(x: int | None): ...           # int or None  (modern, 3.10+)

# Union of two real types:
def describe(value: int | str) -> str:
    return f"{value!r} is a {type(value).__name__}"

print(describe(42))                 # 42 is a int
print(describe("hi"))               # 'hi' is a str

Crucially, these annotations do not run. Python never checks them while executing — a function annotated -> int will return a string without complaint. They exist for tools like mypy and your editor:

def double(x: int) -> int:
    return x * 2

# Python ignores the hint at runtime — this just works:
print(double("ab"))                 # abab  (str repeated, no type error!)

# A static checker like mypy WOULD flag double("ab") before you run it.
# The hints live in __annotations__ but never enforce anything:
print(double.__annotations__)       # {'x': <class 'int'>, 'return': <class 'int'>}
from typing import Optional

def parse_or_default(x: Optional[int]) -> int:
    # Optional[int] means "int or None"
    return x if x is not None else 0

print(parse_or_default(5))      # 5
print(parse_or_default(None))   # 0

# Hints do NOT run — Python ignores the declared types:
def double(x: int) -> int:
    return x * 2

print(double("ab"))             # abab  (string repeated — no error!)
print(double.__annotations__)   # {'x': <class 'int'>, 'return': <class 'int'>}

# ✅ Expected output:
# 5
# 0
# abab
# {'x': <class 'int'>, 'return': <class 'int'>}

2 Generics: One Class, Any Element Type

A generic lets you write a container once and reuse it for any element type, while keeping the type information. The classic approach uses a TypeVar and Generic:

from typing import TypeVar, Generic

T = TypeVar("T")                 # a placeholder for "some type"

class Stack(Generic[T]):
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()
    def __len__(self) -> int:
        return len(self._items)

ints: Stack[int] = Stack()       # a checker now knows pop() returns int
ints.push(1)
ints.push(2)
print(ints.pop(), len(ints))     # 2 1

Python 3.12 added a much cleaner built-in syntax — no TypeVar import needed. It means the same thing:

# Python 3.12+ syntax (shown for reference)
class Stack[T]:                  # the [T] declares the type parameter inline
    def __init__(self) -> None:
        self._items: list[T] = []
    def push(self, item: T) -> None:
        self._items.append(item)
    def pop(self) -> T:
        return self._items.pop()

# And generic functions:
def first[T](items: list[T]) -> T:
    return items[0]

Generics are why list[int] and dict[str, float] work: the built-in containers are generic too. Your own classes can join them with either syntax above.

from typing import TypeVar, Generic

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value
    def get(self) -> T:
        return self.value
    def replace(self, new: T) -> "Box[T]":
        return Box(new)

# Works for any element type
int_box: Box[int] = Box(42)
str_box: Box[str] = Box("hello")

print(int_box.get())            # 42
print(str_box.get())            # hello
print(int_box.replace(99).get())  # 99

# ✅ Expected output:
# 42
# hello
# 99

3 Protocol, TypedDict, and Callable

A Protocol describes a shape: any object with the right methods fits, no inheritance required. This is duck typing made checkable. Add @runtime_checkable to also allow isinstance tests:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self) -> str: ...    # any object with draw() qualifies

class Circle:                      # note: does NOT inherit Drawable
    def draw(self) -> str:
        return "drawing a circle"

class Dog:
    def bark(self) -> str:
        return "woof"

def render(shape: Drawable) -> str:
    return shape.draw()

print(render(Circle()))            # drawing a circle
print(isinstance(Circle(), Drawable))  # True  (it has draw)
print(isinstance(Dog(), Drawable))     # False (no draw method)

A TypedDict nails down the keys and value types of a dictionary, and Callable describes a function's signature when you pass functions around:

from typing import TypedDict, Callable

class Movie(TypedDict):
    title: str
    year: int

film: Movie = {"title": "Up", "year": 2009}   # checker validates the shape
print(film["title"], film["year"])             # Up 2009

# Callable[[arg types], return type]
def apply(fn: Callable[[int], int], x: int) -> int:
    return fn(x)

print(apply(lambda n: n * 3, 7))               # 21
from typing import Protocol, runtime_checkable, TypedDict, Callable

@runtime_checkable
class Sized(Protocol):
    def __len__(self) -> int: ...

# Anything with __len__ fits Sized — structural typing
print(isinstance([1, 2, 3], Sized))   # True
print(isinstance("hello", Sized))     # True
print(isinstance(42, Sized))          # False

# TypedDict describes a dict's exact shape
class User(TypedDict):
    name: str
    age: int

u: User = {"name": "Ada", "age": 36}
print(u["name"], u["age"])            # Ada 36

# Callable describes a function passed as data
def run(fn: Callable[[int], int], n: int) -> int:
    return fn(n)
print(run(lambda x: x + 100, 5))      # 105

# ✅ Expected output:
# True
# True
# False
# Ada 36
# 105

🎯 Your Turn: Fill in the Blanks

Complete the runtime-checkable Protocol so the isinstance checks work. Replace each ___ and match the expected output.

# 🎯 YOUR TURN: complete the Protocol
from typing import Protocol, ___

# 1. Mark the Protocol so isinstance() works on it:
@___
class Greeter(Protocol):
    def greet(self) -> str: ...

class English:
    def greet(self):
        return "Hello"

class Number:
    pass

# 2. These checks rely on the decorator above:
print(isinstance(English(), Greeter))  # True
print(isinstance(Number(), Greeter))   # False

# Expected output:
# True
# False

Decorator blank: runtime_checkable — so the class is decorated with @runtime_checkable, which lets isinstance work against the Protocol.

Common Errors to Avoid

❌ Expecting hints to validate at runtime

def f(x: int): ...
f("not an int")     # runs fine! Python never checks the hint

✅ Run a static checker — mypy file.py — to actually catch type mismatches.

❌ isinstance against a Protocol without @runtime_checkable

isinstance(obj, MyProtocol)   # TypeError if MyProtocol isn't @runtime_checkable

✅ Decorate the Protocol with @runtime_checkable before using it in isinstance.

❌ Thinking a TypedDict is a special class at runtime

m: Movie = {"title": "Up", "yr": 2009}   # wrong key — but no runtime error

✅ A TypedDict is just a dict at runtime; only a type checker flags the misspelled key. Verify with mypy.

🎯 Mini Challenge: A Typed Pipeline

Combine a Protocol, a TypedDict, and a Callable: render any object that has a label, store records as typed dicts, and apply a transform function.

from typing import Protocol, runtime_checkable, TypedDict, Callable

@runtime_checkable
class Labeled(Protocol):
    def label(self) -> str: ...

class Product:
    def __init__(self, name: str) -> None:
        self.name = name
    def label(self) -> str:
        return f"[{self.name}]"

class Record(TypedDict):
    sku: str
    price: float

def total(records: list[Record], discount: Callable[[float], float]) -> float:
    return round(sum(discount(r["price"]) for r in records), 2)

p = Product("Widget")
print(p.label())                        # [Widget]
print(isinstance(p, Labeled))           # True

cart: list[Record] = [
    {"sku": "A1", "price": 10.0},
    {"sku": "B2", "price": 20.0},
]
# Apply a 10% discount to every price
print(total(cart, lambda price: price * 0.9))   # 27.0

# ✅ Expected output:
# [Widget]
# True
# 27.0

📚 Keep Learning

Go deeper with the official Python documentation:

Frequently Asked Questions

Lesson complete — your types are precise and reusable now!

You can express alternatives with Optional, Union, and X | Y, write generic containers with TypeVar (or 3.12's inline syntax), describe shapes with Protocol and TypedDict, type functions with Callable, and you understand that hints are checked by mypy, not the interpreter.

Practice quiz

Optional[int] is shorthand for which of these?

  • Union[int, str]
  • int and None together
  • Union[int, None]
  • A required int that cannot be None

Answer: Union[int, None]. Optional[int] means 'int or None' — exactly Union[int, None]. In Python 3.10+ you can also write int | None.

Which is the modern (Python 3.10+) way to write 'int or None'?

  • int | None
  • int & None
  • int + None
  • Optional(int, None)

Answer: int | None. The pipe syntax int | None means the same as Optional[int] / Union[int, None] and is the cleaner modern form.

At runtime, what happens when an argument's value does not match its type hint?

  • Python raises a TypeError immediately
  • The value is auto-converted to the hinted type
  • The function returns None
  • Nothing — Python runs normally; only static checkers flag it

Answer: Nothing — Python runs normally; only static checkers flag it. Hints are not enforced by the interpreter. A function annotated -> int can return a string and run fine; mypy would catch the mismatch beforehand.

What does a TypeVar (e.g. T = TypeVar('T')) provide?

  • A runtime type-conversion helper
  • A placeholder for 'some type' used to build generics
  • A constant that must always be int
  • A way to disable type checking

Answer: A placeholder for 'some type' used to build generics. A TypeVar is a stand-in for an arbitrary type. Combined with Generic[T], it lets a class like Stack[T] preserve element type information.

How do you make a class generic using the classic typing syntax?

  • class Stack(Generic[T]):
  • class Stack<T>:
  • class Stack[T]: (only valid pre-3.12)
  • class Stack(TypeVar):

Answer: class Stack(Generic[T]):. Subclassing Generic[T] with a TypeVar T is the classic approach. The inline `class Stack[T]:` form was added in Python 3.12.

What does a Protocol describe?

  • A required base class every conforming object must inherit
  • A network message format
  • A shape — the methods/attributes an object must have, no inheritance required
  • A frozen set of constants

Answer: A shape — the methods/attributes an object must have, no inheritance required. A Protocol enables structural typing: any object with the right methods fits, without inheriting from the Protocol.

What must you add to a Protocol to use it with isinstance() at runtime?

  • @dataclass
  • @runtime_checkable
  • @staticmethod
  • Nothing — Protocols always support isinstance

Answer: @runtime_checkable. Decorating the Protocol with @runtime_checkable lets isinstance(obj, MyProtocol) work; otherwise it raises a TypeError.

What does a TypedDict let you specify?

  • A dict whose keys are checked for spelling at runtime
  • An immutable dictionary
  • A dict that can only contain integers
  • The exact keys of a dict and the type of each value

Answer: The exact keys of a dict and the type of each value. A TypedDict declares each key and its value type (e.g. class Movie(TypedDict): title: str; year: int). At runtime it is just a normal dict.

What does Callable[[int], int] describe?

  • A list containing an int
  • A function taking one int and returning an int
  • A callable that returns nothing
  • An int that is also callable

Answer: A function taking one int and returning an int. Callable[[arg types], return type] describes a function's signature — here one int parameter and an int return value.

When using @runtime_checkable, why does isinstance('hello', Sized) return True if Sized requires __len__?

  • All objects pass runtime-checkable Protocols
  • Strings explicitly inherit from Sized
  • Structural typing — str has a __len__ method, so it fits the Protocol
  • isinstance ignores the Protocol's methods

Answer: Structural typing — str has a __len__ method, so it fits the Protocol. Protocols match on structure: because str defines __len__, it satisfies a Sized Protocol — no inheritance from Sized needed.

Continue this course

Frequently asked questions

Do type hints actually run or change my program?

No. Python does not enforce type hints at runtime — they are essentially documentation that the interpreter ignores when executing your code. A function annotated to take an int will happily accept a string and run normally. The value comes from static type checkers like mypy or pyright, and from your editor, which read the hints to catch mismatches before you ever run the program. Think of hints as a safety net you choose to check, not a runtime guard.

What is the difference between Optional, Union, and the | syntax?

Union[int, str] means a value may be either an int or a str. Optional[int] is shorthand for Union[int, None] — a value that is an int or None, which you use for parameters that can be missing. Since Python 3.10 you can write the cleaner pipe syntax: int | str instead of Union, and int | None instead of Optional. They mean exactly the same thing; the pipe form is just newer and more readable.

What is a Protocol and how is it different from inheritance?

A Protocol describes a shape — a set of methods or attributes — rather than a specific base class. Any object that has those methods 'fits' the protocol automatically, without inheriting from anything. This is called structural typing or static duck typing: if it has a draw() method, it counts as Drawable. Mark a Protocol with @runtime_checkable to also allow isinstance() checks against it at runtime.

What is a TypedDict for?

A TypedDict lets you describe the exact shape of a dictionary: which keys it has and the type of each value. Instead of an opaque dict[str, object], you declare class Movie(TypedDict): title: str; year: int, so a type checker knows movie['year'] is an int and warns you if you misspell a key or assign the wrong type. At runtime it is just a normal dict — the structure is only checked statically.