W3docs

Python Type Hints

Learn Python type hints: annotating variables, functions, and classes, using the typing module, and checking types with mypy.

Type hints let you attach expected type information to variables, function parameters, and return values. Python does not enforce them at runtime — they are metadata consumed by editors, linters, and type-checkers such as mypy to catch bugs before you run a single line.

This chapter covers:

  • Why type hints matter and when to use them
  • Annotating variables and functions
  • Built-in types and the typing module (List, Dict, Optional, Union, Tuple, Any, Callable)
  • Modern syntax (Python 3.10+)
  • Annotating classes and self
  • Generics and type aliases
  • Static analysis with mypy
  • Common gotchas

Why Type Hints?

Python is dynamically typed: a variable can hold any value of any type. That flexibility is powerful, but it makes large codebases harder to navigate — you cannot know the type of a function's argument just by reading the call site.

Type hints solve this without giving up Python's dynamism:

  • Editors surface errors immediately. VS Code, PyCharm, and others underline type mismatches as you type.
  • Refactoring becomes safer. Change a function signature and the type-checker tells you every call site that breaks.
  • Code is self-documenting. def greet(name: str) -> str communicates the contract without a docstring.
  • Libraries become easier to use. Typed libraries expose autocomplete for every attribute and method.

Type hints were introduced in Python 3.5 via PEP 484. The syntax has been refined in every major release since. The examples below note the minimum Python version where the syntax first became available.

Annotating Variables

Add a colon after the variable name followed by the type:

name: str = "Alice"
age: int = 30
price: float = 9.99
is_active: bool = True

You can also declare a variable's type without assigning a value yet. This is called a forward declaration and is useful inside classes or at module level:

user_id: int   # declared but not yet assigned
user_id = 42

Type annotations on module-level variables do not affect runtime behavior — they are stored in the module's __annotations__ dictionary but otherwise ignored by the interpreter.

Annotating Functions

Place annotations on parameters (after the colon) and on the return value (after -> before the colon that ends the signature):

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

def greet(name: str) -> str:
    return f"Hello, {name}!"

def send_email(to: str, subject: str, body: str) -> None:
    print(f"Sending '{subject}' to {to}")

result: int = add(3, 5)
message: str = greet("Alice")

-> None means the function has no meaningful return value (it returns None implicitly). Omitting the return annotation is also valid, but explicit -> None makes the intent clear.

Default Parameters

Default values go after the annotation:

def connect(host: str, port: int = 8080, secure: bool = False) -> None:
    print(f"Connecting to {host}:{port} (secure={secure})")

connect("example.com")          # uses defaults
connect("example.com", 443, True)

*args and **kwargs

Annotate the element type, not the collection type:

def total(*prices: float) -> float:
    return sum(prices)

def create_user(**fields: str) -> dict:
    return fields

print(round(total(9.99, 4.50, 12.00), 2))   # 26.49
print(create_user(name="Bob", role="admin"))

*prices: float means each positional argument is a float; at runtime prices is still a regular tuple of floats. Similarly, **fields: str means each keyword argument's value is a str.

The typing Module

For anything beyond the basic built-in types, import from the typing module (Python 3.5+). Starting with Python 3.9, many typing types were merged directly into the built-in equivalents (see Modern Syntax below).

List, Tuple, Set, Dict

from typing import List, Tuple, Set, Dict

def first_names(users: List[str]) -> str:
    return users[0] if users else ""

def dimensions() -> Tuple[int, int, int]:
    return (1920, 1080, 32)

def unique_tags(items: List[str]) -> Set[str]:
    return set(items)

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

print(first_names(["Alice", "Bob"]))        # Alice
print(dimensions())                         # (1920, 1080, 32)
print(unique_tags(["py", "web", "py"]))     # {'py', 'web'}
print(word_count("one two one"))            # {'one': 2, 'two': 1}

Optional

Optional[X] is shorthand for Union[X, None]. Use it whenever a value may be absent:

from typing import Optional

def find_user(user_id: int) -> Optional[str]:
    db = {1: "Alice", 2: "Bob"}
    return db.get(user_id)   # returns None if not found

name = find_user(1)
if name is not None:
    print(name.upper())      # ALICE

missing = find_user(99)
print(missing)               # None

A type-checker sees Optional[str] and knows you must check for None before calling string methods on the result. Without the check, it reports an error.

Union

Union[X, Y] means the value can be either type X or type Y:

from typing import Union

def stringify(value: Union[int, float, str]) -> str:
    return str(value)

print(stringify(42))      # 42
print(stringify(3.14))    # 3.14
print(stringify("hi"))    # hi

Union is most useful when a function genuinely accepts multiple unrelated types. If you find yourself writing Union[str, None], use Optional[str] instead — it is more idiomatic.

Callable

Callable[[ArgTypes...], ReturnType] annotates a function that is passed as an argument:

from typing import Callable

def apply_twice(func: Callable[[int], int], value: int) -> int:
    return func(func(value))

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

print(apply_twice(double, 3))   # 12

Callable[[int], int] means: a callable that takes one int argument and returns an int. If the argument list is complex or unknown, use Callable[..., ReturnType].

Any

Any is a special type that disables type-checking for that value. Every type is both assignable to Any and assignable from Any:

from typing import Any

def log(value: Any) -> None:
    print(value)

log(42)
log("hello")
log([1, 2, 3])

Use Any sparingly — it is an escape hatch that removes the very protection type hints provide. It is appropriate when interfacing with untyped third-party code, or during a gradual migration of a large codebase.

Modern Syntax (Python 3.9+, 3.10+)

Built-in generics (Python 3.9+)

From Python 3.9 onwards you can use the built-in types directly as generics, without importing from typing:

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

def first(items: list[int]) -> int | None:
    return items[0] if items else None

print(word_count("cat dog cat"))    # {'cat': 2, 'dog': 1}
print(first([10, 20, 30]))         # 10
print(first([]))                   # None

Use list[str] instead of List[str], dict[str, int] instead of Dict[str, int], and so on.

X | Y union syntax (Python 3.10+)

Python 3.10 introduced the | operator for unions, replacing Union[X, Y] and Optional[X]:

# Python 3.10+
def parse(value: str | int | None) -> str:
    if value is None:
        return "nothing"
    return str(value)

print(parse("hello"))   # hello
print(parse(42))        # 42
print(parse(None))      # nothing

str | None is equivalent to Optional[str]. This syntax is cleaner and easier to read.

Annotating Classes

Annotate instance attributes inside __init__, and add return annotations to methods:

class BankAccount:
    owner: str        # class-level annotation (no default value)
    balance: float

    def __init__(self, owner: str, initial_balance: float = 0.0) -> None:
        self.owner = owner
        self.balance = initial_balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit amount must be positive.")
        self.balance += amount

    def withdraw(self, amount: float) -> bool:
        if amount > self.balance:
            return False
        self.balance -= amount
        return True

    def __repr__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self.balance:.2f})"

account = BankAccount("Alice", 100.0)
account.deposit(50.0)
print(account.withdraw(30.0))   # True
print(account)                  # BankAccount(owner='Alice', balance=120.00)

The annotation on self is always inferred — you never write self: BankAccount. The return type of __init__ is always None.

ClassVar

Use ClassVar[T] (from typing) to mark an attribute that belongs to the class, not each instance:

from typing import ClassVar

class Config:
    MAX_RETRIES: ClassVar[int] = 3
    timeout: int

    def __init__(self, timeout: int) -> None:
        self.timeout = timeout

print(Config.MAX_RETRIES)   # 3

A type-checker warns if you try to set ClassVar on an instance — it is intended to be shared at the class level.

Type Aliases

A type alias gives a long or complex type a shorter, more meaningful name:

from typing import List, Tuple

# Simple alias
UserID = int
Filename = str

# Structured alias
Coordinates = Tuple[float, float]
Matrix = List[List[float]]

def distance(p1: Coordinates, p2: Coordinates) -> float:
    return ((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) ** 0.5

print(distance((0.0, 0.0), (3.0, 4.0)))   # 5.0

From Python 3.12, use the type statement for explicit, inspectable aliases:

# Python 3.12+
type Vector = list[float]
type Matrix = list[Vector]

Generics with TypeVar

TypeVar lets you write a single function that works with any type while still preserving type relationships:

from typing import TypeVar, List

T = TypeVar("T")

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

x: int = first_item([1, 2, 3])       # x is int
s: str = first_item(["a", "b"])      # s is str

The type-checker infers from the argument what T is, and carries that information through to the return type. Without TypeVar, you would have to return Any and lose type safety.

You can constrain TypeVar to a set of allowed types:

from typing import TypeVar

Numeric = TypeVar("Numeric", int, float)

def double(n: Numeric) -> Numeric:
    return n * 2

print(double(4))      # 8   (int)
print(double(2.5))    # 5.0 (float)

Static Type Checking with mypy

mypy is the most widely used static type-checker for Python. Install it with pip:

pip install mypy

Then run it on a file:

mypy my_script.py

Example: catching a bug with mypy

Save the following as demo.py:

def greet(name: str) -> str:
    return f"Hello, {name}!"

result = greet(42)   # passing int instead of str
print(result.upper())

Running mypy demo.py reports:

demo.py:4: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)

Python itself runs the code fine (f-strings coerce any type), but mypy caught the mismatch before you had to discover it in production.

Useful mypy options

FlagEffect
--strictEnable all optional checks (recommended for new projects)
--ignore-missing-importsSuppress errors about third-party stubs
--check-untyped-defsAlso type-check functions without annotations
--disallow-untyped-defsRequire annotations on all function definitions

A mypy.ini (or [tool.mypy] in pyproject.toml) keeps configuration out of the command line:

[mypy]
strict = true
ignore_missing_imports = true

Gradual Typing

You do not have to annotate every function at once. Python supports gradual typing: annotated and unannotated code coexists peacefully. mypy skips unannotated functions by default (unless --check-untyped-defs is set).

A practical approach for an existing codebase:

  1. Add annotations to new code from day one.
  2. Annotate the most-called or most-error-prone functions first.
  3. Enable --strict module by module as coverage improves.
  4. Use Any only where a third-party library is untyped, and add a comment explaining why.

Common Gotchas

Forward references

If a type refers to a class that is defined later in the same file, wrap the name in quotes to make it a string (a forward reference):

class Node:
    def __init__(self, value: int, next: "Node | None" = None) -> None:
        self.value = value
        self.next = next

head = Node(1, Node(2))
print(head.value, head.next.value)   # 1 2

From Python 3.10+, add from __future__ import annotations at the top of the file instead. This makes all annotations lazy strings and eliminates the need for manual quoting.

Annotations at runtime

By default, annotations in Python 3.9 and earlier are evaluated eagerly. That means a forward reference without quotes raises a NameError:

# Works (with quotes):
def clone(self: "MyClass") -> "MyClass": ...

With from __future__ import annotations (Python 3.7+), all annotations are stored as strings and evaluated only when inspected — solving the forward-reference problem automatically.

None vs Optional

A common mistake is annotating a return type as str when the function can actually return None. Always use Optional[str] (or str | None) when None is a possible return:

from typing import Optional

# Wrong — mypy will flag callers that assume this is always str
def get_name(user_id: int) -> str:
    if user_id == 0:
        return None   # type: ignore  — this is the bug

# Correct
def get_name_safe(user_id: int) -> Optional[str]:
    if user_id == 0:
        return None
    return "Alice"

list vs List (version compatibility)

If your code runs on Python 3.8 or earlier, you must use from typing import List and write List[str]. On Python 3.9+, list[str] works directly. If you need to support both, either use the typing imports or add from __future__ import annotations.

Quick Reference

AnnotationMeaning
x: intVariable x is an integer
def f(a: str) -> boolParameter a is str; return value is bool
-> NoneFunction returns nothing meaningful
Optional[str]str or None
Union[int, str]int or str
list[int] / List[int]List of integers
dict[str, int] / Dict[str, int]Dict mapping str to int
tuple[int, str] / Tuple[int, str]Tuple of (int, str)
Callable[[int], str]Function taking int, returning str
AnyAny type (disables checking)
ClassVar[T]Class-level attribute
TypeVar("T")Generic type variable

Practice

Practice
What does Optional[str] mean in a Python type hint?
What does Optional[str] mean in a Python type hint?
Practice
Which annotation correctly types a function that accepts a list of integers and returns a single integer?
Which annotation correctly types a function that accepts a list of integers and returns a single integer?
Practice
What is the purpose of TypeVar in the typing module?
What is the purpose of TypeVar in the typing module?
Was this page helpful?