Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced+150 XP

Type Hints and Static Typing with mypy

Adding types to Python without losing what makes it Python — annotations, generics, and catching bugs before runtime.

35 min August 2026
// Part 01 — Type Hints Change Nothing at Runtime

Python Is Still Fully Dynamically Typed

Type hints are exactly what the name says — hints. Python's interpreter reads them, stores them, and then does nothing further with them at runtime. Adding type hints to a function does not make Python check argument types when it is called, and does not change how the code executes in any way.

Type hints are not enforced at runtime
def add(a: int, b: int) -> int:
    return a + b

print(add(3, 4))         # 7 — works, as expected
print(add("3", "4"))     # "34" — ALSO runs fine! Python never checked the types at all
⚠️ Important
This surprises almost everyone coming from a statically-typed language. Type hints are purely documentation and tooling input — real enforcement requires running a separate static type checker (like mypy, covered in this module) as a development-time step, completely separate from actually running the program.
// Part 02 — Basic Annotations

Annotating Variables, Parameters, and Return Values

The basic annotation syntax
name: str = "Asha"
age: int = 30
price: float = 19.99
is_active: bool = True

def greet(name: str, times: int = 1) -> str:
    return (f"Hello, {name}! " * times).strip()

def log_event(message: str) -> None:      # -> None means "returns nothing meaningful"
    print(message)

The value these annotations provide is entirely about tooling and readability, not runtime behaviour: your editor can now warn you immediately if you pass the wrong type, autocomplete becomes far more accurate (since the editor knows exactly what a variable's type is), and anyone reading the function signature knows what it expects without reading the implementation.

// Part 03 — Optional and Union

Expressing "Could Also Be None" or "One of Several Types"

Optional — a value that might be None
from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    # Optional[str] means "either a str, or None"
    if user_id in database:
        return database[user_id]
    return None
Union — a value that could be one of several specific types
from typing import Union

def parse_id(raw: Union[str, int]) -> int:
    return int(raw)
Python 3.10+ — the | syntax replaces both Optional and Union
def find_user(user_id: int) -> str | None:      # same meaning as Optional[str]
    ...

def parse_id(raw: str | int) -> int:             # same meaning as Union[str, int]
    return int(raw)

The | syntax (added in Python 3.10) is now the preferred, more concise way to write these — Optional and Union from the typing module still work identically and remain common in codebases that need to support older Python versions, but new code targeting 3.10+ generally uses | directly.

// Part 04 — Generics

Annotating What's Inside a Collection

Generic collection types
def get_names(users: list[dict[str, str]]) -> list[str]:
    return [u["name"] for u in users]

def word_counts(text: str) -> dict[str, int]:
    counts: dict[str, int] = {}
    for word in text.split():
        counts[word] = counts.get(word, 0) + 1
    return counts

def unique_ids(ids: list[int]) -> set[int]:
    return set(ids)

Modern Python (3.9+) lets you use the built-in collection types directly as generics — list[int], dict[str, int] — rather than the older, more verbose typing.List[int]/typing.Dict[str, int] forms required on earlier versions. The built-in forms are now the standard, idiomatic choice for any project not specifically constrained to pre-3.9 Python.

// Part 05 — Structural Typing With Protocol

Typing Without Requiring Inheritance

The Abstract Base Classes module covered enforcing a contract through explicit inheritance. typing.Protocol offers a different approach — describing a required shape (which methods/attributes must exist) without requiring any class to explicitly inherit from it at all. Any object satisfies a Protocol just by having the right methods, structurally — much closer to duck typing, but checkable ahead of time by a type checker.

A Protocol — no inheritance required to satisfy it
from typing import Protocol

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

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

class ToyDuck:
    def quack(self) -> str:
        return "Squeak-quack"

def make_it_quack(duck: Quacks) -> str:
    return duck.quack()

make_it_quack(RealDuck())   # type-checks fine — RealDuck was never declared to inherit from Quacks
make_it_quack(ToyDuck())    # ALSO type-checks fine — it just needs a matching quack() method

This is genuinely valuable when you want the safety of static type checking on code that is intentionally written in Python's duck-typed style — libraries you do not control (and cannot add inheritance to) can still satisfy a Protocol, as long as they happen to have the right method shape.

// Part 06 — Running mypy

Actually Checking the Types You've Annotated

Installing and running mypy
pip install mypy
mypy your_script.py
mypy catching a real bug before the code ever runs
def calculate_total(prices: list[float]) -> float:
    return sum(prices)

calculate_total(["19.99", "29.99"])   # a list of STRINGS, not floats

# Running mypy on this file reports, without ever executing the code:
# error: Argument 1 to "calculate_total" has incompatible type "list[str]";
#        expected "list[float]"

This is the entire point of static typing in Python: the bug above — passing a list of strings where floats were expected — would not raise any error at runtime here (Python would happily sum() a list of numeric-looking strings incorrectly, or crash somewhere downstream depending on what happens next), but mypy catches it before the program is ever run, typically wired into CI so a type error blocks a pull request the same way a failing test would.

// Part 07 — Gradual Typing Strategy

Adopting Type Hints on an Existing, Untyped Codebase

Almost no real codebase gets type-annotated all at once — Python's typing system is designed to be adopted gradually, and mypy fully supports a codebase that is only partially annotated.

mypy treats unannotated code as implicitly 'Any' — meaning 'skip checking here'
def legacy_function(x, y):     # no annotations at all
    return x + y                # mypy does not check this function's internals by default

def new_function(x: int, y: int) -> int:   # fully annotated
    return x + y                             # mypy DOES check this one

A pragmatic, genuinely common real-world strategy: start by annotating new code and any function currently being touched for other reasons (a natural side effect of a normal PR, not a dedicated typing effort), enable mypy in CI in a lenient/permissive mode so it does not block on the large amount of still-untyped legacy code, then gradually tighten mypy's strictness settings (module by module, or file by file) as coverage grows over time — rather than attempting a single enormous, risky PR that annotates the entire codebase at once.

// Part 08 — Real World
💼 What This Looks Like at Work

Catching a Payments Bug in CI, Not in Production, at a Nashville Fintech Company

Scenario — Fintech company, Nashville · CI type-check gate

An engineer refactors a function's return type from returning cents as an int to returning dollars as a float, correctly updating the type hint. A different call site elsewhere in the payments module, untouched by this PR, still treats the return value as integer cents and multiplies it directly into a database write.

The change and the now-inconsistent call site
def get_transaction_amount(txn_id: str) -> float:   # changed from -> int
    ...
    return amount_in_dollars

# Elsewhere, in a completely different file, untouched by this PR:
def record_fee(txn_id: str) -> None:
    amount = get_transaction_amount(txn_id)
    fee_cents = amount * 100          # was correct when amount was already cents; now double-converts

What actually happened

mypy, running in CI on every pull request, immediately flagged that record_fee's usage was inconsistent with the type system's understanding of the surrounding code once the annotation propagated — specifically catching that a value now documented as dollars was still being treated with cents-oriented logic elsewhere. The PR was blocked in review, not caught by a customer noticing an incorrect fee days later. The team's own retrospective note: "a type checker doesn't understand business logic, but it absolutely understands when two pieces of code disagree about what a value represents — and that disagreement is exactly what caused this bug."

// Part 09 — Misconceptions

Four Misconceptions About Type Hints

✕ ""Adding type hints makes Python check types at runtime, like a statically typed language""
Type hints are completely ignored by the Python interpreter at runtime — calling add("3", "4") on a function annotated as add(a: int, b: int) runs without any error. Enforcement only happens if you separately run a static checker like mypy as a development-time step.
✕ ""You have to fully type-annotate a codebase before type hints provide any value""
mypy is explicitly designed for gradual adoption — unannotated functions are simply not checked (treated as implicitly Any), so you can annotate incrementally, starting with new code, without needing a single large all-at-once migration.
✕ ""typing.Protocol is basically the same thing as an abstract base class""
An ABC requires explicit inheritance to satisfy its contract; a Protocol is satisfied purely structurally — any object with the right method shape works, with no inheritance relationship required at all, much closer to duck typing but checkable statically.
✕ ""list[int] and typing.List[int] mean genuinely different things""
They mean the same thing — list[int] (using the built-in type directly as a generic) became valid syntax in Python 3.9+ and is now the preferred, more concise form. typing.List[int] is the older form, still supported for compatibility with earlier Python versions.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

Do type hints affect how Python code actually runs?
No — they are purely metadata, ignored by the interpreter at runtime. A function can be called with arguments of the wrong annotated type and will run without error. Real enforcement requires a separate static type checker like mypy, run as a development-time step distinct from executing the program.
What is the difference between Optional[str] and str | None?
They mean exactly the same thing — both express "either a str, or None". str | None (Python 3.10+) is the newer, more concise syntax; Optional[str] from the typing module is the older form still used for compatibility with earlier Python versions.
How does typing.Protocol differ from an abstract base class for defining a contract?
An ABC requires explicit inheritance — a class must subclass it and implement its abstract methods. A Protocol is satisfied structurally, purely by having methods/attributes of the right shape, with no inheritance relationship required — closer to duck typing, but still checkable by a static type checker ahead of runtime.
Why is mypy usually run as a CI step rather than something enforced at runtime?
It performs STATIC analysis — reading the code and its type annotations to find inconsistencies without ever executing it, which is exactly why it can catch bugs (like the dollars-vs-cents mismatch in the Real World example) before the code path is ever exercised by a test or a real user, rather than waiting for it to fail at runtime.
How would you adopt type hints on a large, currently untyped codebase without a risky big-bang migration?
Gradually — annotate new code and functions already being touched for other reasons, run mypy in CI in a lenient/permissive mode so it doesn't block on the large amount of still-untyped legacy code, and tighten strictness module by module as coverage grows, rather than attempting to annotate everything in one enormous PR.
// Common Mistakes

Type Hint Mistakes Beginners Make Constantly

Believing a type-annotated function is now protected from being called with the wrong type
Type hints alone provide zero runtime protection — the function still runs normally with mismatched argument types unless you separately run mypy (or add explicit runtime validation, which is a different, additional mechanism entirely).
Using typing.List, typing.Dict on modern Python instead of the built-in generics
Not wrong, but unnecessarily verbose on Python 3.9+ — list[int] and dict[str, int] work directly as generics and are the preferred modern style, reserving the typing module forms mainly for codebases targeting older Python versions.
Forgetting -> None on a function that returns nothing meaningful
A function with no explicit return annotation is left unannotated for its return type by mypy's inference, which is usually fine, but omitting -> None on a function that genuinely never returns a meaningful value loses useful, cheap documentation and can hide a mistake where a caller wrongly tries to use its return value.
Annotating a mutable default argument's type without addressing the actual mutable-default bug
def add_item(items: list[str] = []) -> ... is still the classic mutable-default-argument trap covered in the Constructors module — the type annotation documents the type correctly but does nothing to fix the underlying shared-default bug. Use items: list[str] | None = None and create a new list inside the function body instead.
// Error Library

Errors You Will Hit With Type Hints & mypy — And Exactly Why

error: Argument 1 to "calculate_total" has incompatible type "list[str]"; expected "list[float]"
Cause: mypy statically detected that a call site passes a value whose annotated/inferred type does not match the function's declared parameter type.
Fix: Either fix the call site to pass the correct type, or, if the function genuinely should accept both, widen its parameter type (e.g. to a Union) to reflect reality.
error: Function is missing a return type annotation
Cause: This appears specifically when mypy is run in a "strict" mode requiring every function to be fully annotated, and one is not.
Fix: Add the missing -> ReturnType annotation, or relax mypy's strictness setting if the codebase is still in a gradual-adoption phase.
error: Incompatible return value type (got "int", expected "str")
Cause: A function's actual return statement(s) do not match its declared -> annotation.
Fix: Fix either the return statement or the annotation, whichever one is actually wrong.
TypeError: 'type' object is not subscriptable
Cause: Using the built-in generic syntax (list[int], dict[str, int]) on a Python version older than 3.9, where this syntax is not supported at all — this is a genuine RUNTIME error, unlike most type-hint issues.
Fix: Upgrade to Python 3.9+, or use "from __future__ import annotations" (which defers annotation evaluation) on 3.7+, or fall back to typing.List/typing.Dict on older versions.

🎯 Key Takeaways

  • Type hints are ignored by the Python interpreter at runtime — they change nothing about how code actually executes.
  • Real enforcement comes from a separate static type checker, mypy being the standard choice, typically run in CI to catch mismatches before code is ever executed.
  • str | None (3.10+) and Optional[str] mean the same thing; list[int] (3.9+) and typing.List[int] mean the same thing — the built-in/pipe forms are the modern preferred style.
  • typing.Protocol enables structural typing — satisfying a contract by shape alone, with no inheritance required, unlike an abstract base class.
  • mypy supports gradual adoption — unannotated code is simply not checked, so a codebase can be typed incrementally rather than all at once.
  • A type checker catches inconsistencies BETWEEN pieces of code about what a value represents — exactly the class of bug shown in the Real World example, caught in CI instead of production.

What comes next

Module 37 covers working with real-world APIs in Python — the requests library, authentication, and the timeout mistake that causes production incidents.

Module 37 → Working with APIs in Python
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...