Engineering Notes
Home/Languages/Python/Type Hints
Languages · Python

Type Hints

A type hint (param: Type, -> ReturnType, name: Type) documents the intended type. Python's interpreter does not enforce it at runtime — add("x", "y") runs "x" + "y" happily and returns "xy", no error, no warning. Hints exist for static analysis tools (mypy, pyright), IDEs (autocomplete, inline errors), and human…

💻 Languages● Advanced⏱ 6 min read🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Basic Annotations
  • Optional and Union Types
  • Generic Containers
  • TypeVar — Generic Functions
  • Protocol — Structural Typing

1Basic Annotations

python
def add(a: int, b: int) -> int:
    return a + b

name: str = "ana"
age: int
age = 30

A type hint (param: Type, -> ReturnType, name: Type) documents the intended type. Python's interpreter does not enforce it at runtime — add("x", "y") runs "x" + "y" happily and returns "xy", no error, no warning. Hints exist for static analysis tools (mypy, pyright), IDEs (autocomplete, inline errors), and human readers — not for the runtime.

2Optional and Union Types

python
from typing import Optional, Union

def find_user(user_id: int) -> Optional[dict]:   # equivalent to Union[dict, None]
    ...

def parse(value: Union[int, str]) -> int:          # accepts EITHER an int or a str
    ...

# Python 3.10+ pipe syntax — equivalent, generally preferred in new code
def find_user_new(user_id: int) -> dict | None:
    ...

def parse_new(value: int | str) -> int:
    ...

Optional[X] is exactly Union[X, None] — a value that is either type X or None. Union[A, B, ...] means "any one of these types." The | syntax (X | None, A | B) does the same thing with less ceremony and no import needed, available from Python 3.10 onward.

3Generic Containers

python
def total(nums: list[int]) -> int:
    return sum(nums)

def lookup(mapping: dict[str, int], key: str) -> int | None:
    return mapping.get(key)

def first(items: tuple[int, ...]) -> int:   # variable-length homogeneous tuple
    return items[0]

def pair() -> tuple[str, int]:                # fixed-shape tuple: exactly (str, int)
    return ("ana", 30)

Built-in generics (list[int], dict[str, int], tuple[...], set[str]) describe not just the container type but what it contains — list[int] means "a list whose elements are ints," which static checkers use to catch, e.g., appending a string to a list[int]. (Python 3.9+ supports this directly on the built-ins; older code uses typing.List[int] etc., which still works but is considered legacy style.)

4TypeVar — Generic Functions

python
from typing import TypeVar

T = TypeVar("T")

def first_item(items: list[T]) -> T:
    return items[0]

x: int = first_item([1, 2, 3])       # a type checker infers T=int here
y: str = first_item(["a", "b"])       # and T=str here

TypeVar lets a function's input and output types be linked generically — a type checker infers a concrete type for T at each call site based on the argument passed, and checks that the return type is consistent with that same T. Without it, first_item(items: list) -> object would type-check but lose the connection between "what went in" and "what comes out."

5Protocol — Structural Typing

python
from typing import Protocol

class SupportsQuack(Protocol):
    def quack(self) -> str: ...

class Duck:
    def quack(self) -> str:
        return "Quack!"

class Person:
    def quack(self) -> str:
        return "I'm quacking!"

def make_it_quack(entity: SupportsQuack) -> str:
    return entity.quack()

make_it_quack(Duck())     # OK — has a matching quack() method
make_it_quack(Person())    # ALSO OK — Person never inherits from SupportsQuack,
                             # but it structurally matches (duck typing, formalized)

Protocol defines an interface by shape (which methods/attributes must exist) rather than by explicit inheritance — any class whose objects have a matching quack() -> str method satisfies SupportsQuack, with zero coupling to it. This formalizes Python's long-standing "duck typing" convention (if it walks like a duck and quacks like a duck...) in a way static checkers can actually verify.

6TYPE_CHECKING — Avoiding Circular Imports for Hints Only

python
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from other_module import OtherClass   # only imported by type checkers, never at runtime

def process(item: "OtherClass") -> None:    # quoted, since OtherClass isn't actually
    ...                                        # imported when this code really runs

typing.TYPE_CHECKING is False at runtime and True only from a static type checker's perspective — so imports needed purely for annotations (and nowhere else in the function bodies) can be guarded behind it, avoiding a real circular-import failure at runtime while still giving the checker what it needs. The annotation itself is typically written as a string ("OtherClass", a "forward reference") since the name genuinely doesn't exist at runtime under this guard.

Under the HoodHints Are Erased at Runtime▾
python
def add(a: int, b: int) -> int:
    return a + b

print(add.__annotations__)   # {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
print(add("x", "y"))           # "xy" — runs fine, the hints were never checked
A static checker reads annotations and reports a type error without running the code, while CPython only stores them in __annotations__ and runs the call unchecked
A static checker reads annotations and reports a type error without running the code, while CPython only stores them in __annotations__ and runs the call unchecked

Annotations are stored in a function's __annotations__ dict (or a class's, for variable annotations) purely as metadata — nothing in the interpreter consults them during a normal call. This is why add("x", "y") executes without error: type hints are a documentation and tooling layer bolted onto a dynamically-typed language, not a language-level enforcement mechanism (contrast with a statically-typed language, where this would be a compile error). Enforcement, when wanted, comes from a separate tool (mypy, pyright) run as a linting step, or from a library (like pydantic) that explicitly validates values against annotations at runtime — the language itself stays silent.

⚠️ Common Pitfalls

  1. Assuming type hints prevent runtime type errors — they don't; nothing stops add("x", "y") from running. Enforcement requires a separate static checker or explicit runtime validation.
  2. Forgetting Optional/| None when a parameter or return value can genuinely be None — annotating def find(id: int) -> dict when the function can return None on a miss gives a static checker false confidence and misleads readers.
  3. Circular imports from unguarded hint-only imports — importing a class solely to annotate a parameter, without TYPE_CHECKING, can create a real circular-import failure that wouldn't exist if the import were only needed for hints.
  4. Using typing.List/typing.Dict in new code — the built-ins (list[int], dict[str, int]) have supported subscripting since Python 3.9 and are now preferred; the typing generic aliases are legacy but still work for older codebases/Python versions.
  5. Expecting Protocol to require explicit inheritance like an ABC — the whole point is structural matching; a class satisfies a Protocol by having the right shape, not by subclassing it (though it can subclass it too, which is allowed but not required).

📋 Quick Reference

python
def f(a: int, b: str = "x") -> bool: ...       # basic parameter/return annotations
x: list[int] = []                                 # generic container annotation

from typing import Optional, Union
Optional[X]              # same as X | None
Union[A, B]                # same as A | B
X | None                     # 3.10+ pipe syntax, preferred

from typing import TypeVar
T = TypeVar("T")
def first(xs: list[T]) -> T: ...                     # generic function

from typing import Protocol
class HasName(Protocol):
    name: str                                            # structural interface

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from mod import Thing                                  # hint-only import, no runtime cost

func.__annotations__          # where hints actually live — inspected, never enforced, by Python itself

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Why use type hints in Python?core▾

They document intent, enable static checking (mypy, pyright) to catch bugs before runtime, power editor tooling, and drive frameworks like FastAPI and Pydantic. They don't enforce types at runtime by themselves.

Follow-ups interviewers ask
  • Runtime validation? Pydantic or similar.
Q2What are common typing constructs?core▾

Built-in generics (list[int], dict[str, User]), X | None (Optional), unions, Callable, TypedDict, Literal, Protocol, TypeVar/generics (def first[T](xs: list[T]) -> T in 3.12), Final, Self.

Follow-ups interviewers ask
  • Any vs object? Any disables checks; object requires narrowing.
Q3What is a Protocol?core▾

A structural type: any class with matching methods satisfies it without inheritance, bringing duck typing to static checking.

Follow-ups interviewers ask
  • runtime_checkable? Allows isinstance checks (methods only).
Q4How do you adopt typing in a large existing codebase?senior▾

Gradually: start with public APIs and new code, enable stricter mypy/pyright settings per module, add stubs for libraries, and track coverage; avoid blanket Any.

Follow-ups interviewers ask
  • Strict options? disallow_untyped_defs, strict = true per package.
Q5TypedDict vs dataclass vs Pydantic model?core▾

TypedDict types dict shapes (e.g. JSON) without runtime cost; dataclasses create real classes with generated methods; Pydantic models validate and parse data at runtime.

Follow-ups interviewers ask
  • Which for API input? Pydantic.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.