Skip to content
elephantoo

Type hints & static typing

Lesson 28 of 38 16 min read

Annotations, unions and Optional, collections.abc types, generics, Protocols and checking code with mypy.


Python is dynamically typed, and that won't change. But since Python 3.5 you can annotate variables, parameters and return values with their expected types. The interpreter ignores these hints at runtime, yet they're enormously valuable: editors autocomplete better, type checkers like mypy and pyright catch bugs before you run anything, and readers understand your code faster. Most professional codebases use them, and frameworks like FastAPI and Pydantic even use them at runtime.

The basics#

Python
def greet(name: str, times: int = 1) -> str:
    return ", ".join([f"Hello {name}"] * times)


age: int = 36
price: float = 99.5
names: list[str] = ["Ada", "Grace"]
scores: dict[str, int] = {"Ada": 92}
point: tuple[int, int] = (3, 4)
unique: set[str] = {"py"}

print(greet("Ada", 2))
print(greet.__annotations__)
Output
Hello Ada, Hello Ada
{'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
  • Parameters: name: type, with defaults after: times: int = 1.
  • Return type: -> type before the colon. Use -> None for functions that don't return anything.
  • Since Python 3.9 you use the built-in collection types directly with square brackets: list[str], dict[str, int]. Older code imports List, Dict from typing — still valid, but no longer needed.

Hints are not enforced at runtime#

Python
def double(n: int) -> int:
    return n * 2


print(double("ha"))     # runs fine — Python doesn't check
Output
haha

That's where a type checker comes in.

Checking types with mypy#

Install mypy into your virtual environment and run it on your code:

shop.py
def total(prices: list[float], discount: float = 0.0) -> float:
    return sum(prices) * (1 - discount)


def label(amount: float) -> str:
    return "₹" + amount            # bug: str + float


print(total([10.0, 20.0], "10%"))  # bug: wrong argument type
Terminal
pip install mypy
mypy shop.py
Output
shop.py:6: error: Unsupported operand types for + ("str" and "float")  [operator]
shop.py:9: error: Argument 2 to "total" has incompatible type "str"; expected "float"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

Both bugs were found without running the program. VS Code's Pylance extension (based on pyright) shows the same errors as red squiggles while you type. Many teams run mypy or pyright in CI on every pull request.

Optional values and unions#

Python
def find_user(user_id: int) -> str | None:
    users = {1: "Ada", 2: "Grace"}
    return users.get(user_id)


def parse_id(value: int | str) -> int:
    return value if isinstance(value, int) else int(value)


name = find_user(3)
if name is not None:            # the checker knows `name` is str inside this block
    print(name.upper())
else:
    print("not found")
print(parse_id("42") + parse_id(8))
Output
not found
50

str | None (Python 3.10+) means "a string or None" — the older spelling is Optional[str]. The is not None check narrows the type: type checkers understand isinstance and None checks, so they'll warn you if you call .upper() without handling None first. This alone prevents a huge class of AttributeError: 'NoneType' object has no attribute ... bugs.

Useful types from typing and collections.abc#

Python
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from typing import Any, Literal, TypedDict, Final


def apply(func: Callable[[int], int], values: Iterable[int]) -> list[int]:
    return [func(v) for v in values]


def countdown(n: int) -> Iterator[int]:          # generators return iterators
    while n > 0:
        yield n
        n -= 1


def first(items: Sequence[str]) -> str:          # accepts list, tuple, str...
    return items[0]


def set_mode(mode: Literal["read", "write"]) -> None:   # only these strings
    print("mode:", mode)


class Movie(TypedDict):                           # the shape of a dict
    title: str
    year: int


MAX_SIZE: Final = 100                             # shouldn't be reassigned
config: dict[str, Any] = {"debug": True, "port": 8000}   # Any: opt out of checking

print(apply(lambda x: x * 10, [1, 2]))
print(list(countdown(3)), first(("a", "b")))
set_mode("read")
m: Movie = {"title": "Sholay", "year": 1975}
print(m["title"])
Output
[10, 20]
[3, 2, 1] a
mode: read
Sholay

Tip: accept abstract types in parameters (Iterable, Sequence, Mapping) so callers can pass any suitable container, and return concrete types (list, dict) so callers know exactly what they get.

Type aliases and generics#

Python 3.12 added a clean syntax for type aliases and generic functions/classes:

Python
type Matrix = list[list[float]]          # type alias (3.12+)
type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None


def first_or_default[T](items: list[T], default: T) -> T:    # generic function (3.12+)
    return items[0] if items else default


class Stack[T]:                                              # generic class (3.12+)
    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()


grid: Matrix = [[1.0, 0.0], [0.0, 1.0]]
print(first_or_default([3, 4], 0), first_or_default([], "none"))
s = Stack[int]()
s.push(5)
print(s.pop(), len(grid))
Output
3 none
5 2

T is a type variable: first_or_default([3, 4], 0) is known to return an int, while a list of strings gives a str. In code that must support Python 3.11 or older, you'll see the equivalent T = TypeVar("T") and class Stack(Generic[T]).

Protocols: static duck typing#

Python favours duck typing — "anything with a .read() method". A Protocol expresses that to the type checker without forcing inheritance:

Python
from typing import Protocol


class Drawable(Protocol):
    def draw(self) -> str: ...


class Circle:                     # doesn't inherit from Drawable
    def draw(self) -> str:
        return "○"


class Square:
    def draw(self) -> str:
        return "□"


def render(shapes: list[Drawable]) -> str:
    return " ".join(s.draw() for s in shapes)


print(render([Circle(), Square(), Circle()]))
Output
○ □ ○

Any class with a matching draw() method is accepted. Pass an object without one, and mypy reports an error.

Annotating classes#

Python
from dataclasses import dataclass, field
from typing import ClassVar, Self


@dataclass
class Account:
    owner: str
    balance: float = 0.0
    history: list[float] = field(default_factory=list)
    bank_name: ClassVar[str] = "Elephantoo Bank"     # class attribute, not a field

    def deposit(self, amount: float) -> Self:         # returns this same type
        self.balance += amount
        self.history.append(amount)
        return self


acct = Account("Ada").deposit(100).deposit(50)
print(acct.balance, acct.history, Account.bank_name)
Output
150.0 [100, 50] Elephantoo Bank

Self (3.11+) is the right return type for methods that return their own instance, so chaining works correctly in subclasses too.

Gradual typing: how much to annotate#

You don't have to annotate everything at once. Sensible guidelines:

  • Annotate function signatures (parameters and returns), especially public ones. That's where most value comes from.
  • Let the checker infer local variables: count = 0 is obviously an int.
  • Annotate empty containers, which can't be inferred: items: list[str] = [].
  • Use Any sparingly as an escape hatch.
  • Turn on stricter mypy settings over time (mypy --strict), typically configured in pyproject.toml.

Runtime uses of type hints#

Although Python ignores hints when running ordinary code, libraries can read them. dataclasses uses them to find fields; Pydantic and FastAPI use them to validate and convert incoming data:

Python
from typing import get_type_hints


def register(name: str, age: int, tags: list[str] | None = None) -> bool:
    return True


for param, hint in get_type_hints(register).items():
    print(param, "->", hint)
Output
name -> <class 'str'>
age -> <class 'int'>
tags -> list[str] | None
return -> <class 'bool'>

Common mistakes#

  • Expecting runtime enforcement — use a type checker (or Pydantic for untrusted input).
  • Annotating with values instead of types: def f(x: 5) or items: [].
  • Forgetting | None when a function can return None.
  • Overusing Any, which silently turns checking off.
  • Mutable default with a hint: items: list[str] = [] in a function signature is still the shared-default bug — use items: list[str] | None = None.

What's next#

You've finished the intermediate section! The advanced section starts with a tool you'll use for searching, validating and cleaning text: regular expressions.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does Python do at runtime if you call def double(n: int) -> int with a string?

  2. 2.How do you annotate a parameter that can be a string or None in modern Python?

  3. 3.What is a Protocol used for?

Finished reading?

Mark this lesson complete to track your progress.