Module 8 · Python Under the Hood ⏱ 18 min

Type Hinting & Annotations

By the end of this lesson you will be able to:
  • Read and write parameter and return annotations on a function signature
  • Use built-in generic hints such as list[int], dict[str, int], and str | None
  • Recognise that hints are advisory at runtime and enforced only by static tooling

Python's famous flexibility cuts both ways. The language never checks what kind of value you hand to a function, so a routine that adds two integers will just as happily glue two strings together and hand back a result nobody asked for. The wrong answer appears with no error, and the defect surfaces far from the line that caused it — usually inside some other function that trusted the value it received.

Contrast that with a language that refuses to even run a call passing the wrong shape of data. Python trades that early safety for brevity and speed of writing. A type hint is how you win some of that safety back without giving the brevity up: a short note on a signature declaring what type each value should be, so editors, linters, and reviewers can catch a mismatch before the code ever runs.

flowchart LR
  subgraph N["without hints"]
    A1["call add(2, oops)"] --> A2["runs on, no warning"] --> A3["crash deep inside"]
  end
  subgraph H["with hints"]
    B1["call add(2, oops)"] -.->|"static checker flags"| B2["error at the call site"]
  end
  style A3 fill:#b45309,color:#fff
  style B2 fill:#3776ab,color:#fff
Without hints a wrong-typed argument travels silently until something deep inside breaks. With hints, a checker stops it at the call site.

Reading an annotated signature

The syntax layers hints onto a definition you already know. Each parameter name gains a colon followed by a type, and the return value is announced with an arrow after the closing parenthesis.

def greet(name: str, times: int = 1) -> str:
    return (name + "! ") * times

name: str declares that name should be a string; times: int says the count is a whole number; -> str promises the function hands back a string. None of these alter how the body runs. They are notes, read by people and by tooling, and quietly set aside by the interpreter while the code executes.

Define an annotated function and call it. The hints ride along; they do not change the result.
def greet(name: str, times: int = 1) -> str:
    return (name + "! ") * times

print(greet("Hi"))
print(greet("Yo", 3))

Hints for collections

A single scalar type only gets you so far, and a plain int or str hint says nothing about what a list actually contains — which is usually the part that matters. Real functions pass lists, dictionaries, tuples, and values that might be missing, so the hint vocabulary grows to match. Since Python 3.9 you write collections with the very types you already use at runtime.

  • list[int] — a list whose elements are integers.
  • dict[str, int] — a dictionary mapping strings to integers.
  • tuple[int, str] — a fixed pair of an integer then a string.
  • str | None — either a string or nothing at all.

That last one matters constantly. A function that searches and might come up empty returns str | None, which warns every caller to handle the missing case instead of assuming a value is always waiting.

flowchart LR
  P["name : str"] -->|"annotates the input"| F["the signature"]
  R["-> str"] -->|"annotates the output"| F
  style F fill:#3776ab,color:#fff
Annotate each parameter after a colon; announce the return after the arrow. Both are notes pinned to the signature.
A function that takes list[int] and hands back dict[str, int]. The hint describes the whole shape in one line.
def count_by_parity(nums: list[int]) -> dict[str, int]:
    counts = {"even": 0, "odd": 0}
    for n in nums:
        if n % 2 == 0:
            counts["even"] += 1
        else:
            counts["odd"] += 1
    return counts

print(count_by_parity([1, 2, 3, 4]))

Why bother, if nothing runs differently?

If Python never enforces a hint, what is the point? The value lives outside the runtime. Your editor uses hints to sharpen autocomplete and flag obvious mistakes as you type. A static checker such as mypy reads the whole project and reports type conflicts inside CI, before a human ever runs the code. And a good hint is documentation you can trust: instead of tracing the body to learn what comes back, you read -> dict[str, int] and understand at a glance. Because nothing executes differently, annotating a working program cannot introduce a bug — the worst it does is surface questions about code that was already ambiguous.

Gradual typing: a little at a time

Hints are opt-in, not all-or-nothing, which is why this is called a gradual system. You can annotate one critical function and leave the rest of a file untouched; a checker simply has nothing to say about the parts you skipped. That makes hints practical for an existing codebase. Add them where the risk is highest — public APIs and the functions many callers depend on — and let the rest catch up over time. A function with no annotations at all is treated as accepting Any, the type that silences the checker, so old code keeps working unchanged while new code grows steadily safer.

The trap, live. The hint says int; Python shrugs and concatenates the string anyway.
def double(x: int) -> int:
    return x * 2

print(double(5))
print(double("hi"))

Optional values and the missing answer

Many functions can fail to find what you were looking for, and the honest return type is a value or nothing at all. Writing -> str | None makes that possibility impossible to overlook. Every caller who reads the signature is reminded to handle the empty case, and a checker will protest if they treat the result as though it were always a string. A return of str | None is therefore different in kind from str: the first demands a check, the second promises a value. The older spelling Optional[str] means exactly the same thing and still turns up in code written before Python 3.10, so recognise both forms when you meet them.

flowchart LR
  H["hints written in the file"] --> R["runtime: ignored"]
  H --> C["static checker: enforced"]
  R -.->|"no runtime error"| Bug["a bad call slips through"]
  C -.->|"error before it runs"| Caught["caught early, in CI"]
  style Bug fill:#b45309,color:#fff
  style Caught fill:#3776ab,color:#fff
Hints travel two paths: the interpreter ignores them at runtime, while a static checker enforces them before the code runs.
Exercise

Write format_price(price: float) -> str that returns a price formatted as a dollar string with two decimals — so format_price(9.5) gives "$9.50". Return the string; do not print.

def format_price(price: float) -> str:
    # your code here
    pass
Exercise

Hints change nothing. This function is fully annotated, but annotations never run. Predict the single line it prints.

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

print(add(2, 3))
Exercise

This function is annotated to take an int, but it must actually REJECT non-integers at runtime — and a hint alone never does that. Add a guard so it raises TypeError for anything that is not an int, while still returning x * 2 for a genuine integer.

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

At runtime, what happens if you call a function declared def double(x: int) -> int with double("hi")?

Exercise

Write first_even(nums: list[int]) -> int | None that returns the first even number in nums, or None if there is not one. The annotation matters: the return is int | None because the answer may not exist.

def first_even(nums: list[int]) -> int | None:
    # your code here
    pass

Recap

  • A type hint annotates a parameter (name: str) or a return (-> str); it documents intent without changing how the code runs.
  • Use built-in generics (list[int], dict[str, int]) and str | None for values that may be absent.
  • Hints are advice, not enforcement — Python ignores them at runtime; tools like mypy enforce them statically, in CI.
  • The payoff is tooling and readability: sharper autocomplete, mismatches caught early, and signatures you can trust at a glance.

Next you will build flexible signatures with *args and **kwargs, so a single function can accept as many arguments as a caller cares to pass.

Checkpoint quiz

What does Python do with a type hint while the code runs?

Which annotation says a function returns either a string or nothing?

Go deeper — technical resources