Type Hints

Type hints let you document the intended types of variables and function signatures directly in code — Python never enforces them at runtime, but tools do.

Python remains dynamically typed: type hints (also called type annotations) add optional, purely informational type information to variables, function parameters, and return values. Python's own runtime almost entirely ignores them — the real value comes from static type checkers (a related, ecosystem-provided tool) and from documenting intent for human readers.

What Type Hints Are — and Are Not

A type hint is a piece of syntax attached to a name (variable, parameter, return value) that states the expected type of the object that name will refer to. Python is still dynamically typed underneath: a name can be rebound to an object of any type regardless of its hint, and the interpreter does not check or enforce hints at runtime in ordinary code.

greet(42) does not raise a TypeError from the annotation itself — f-strings happily stringify an int. The hint is a promise to readers and tools, not a runtime contract enforced by Python.

Type hints are sometimes called 'type annotations'. Both terms refer to the same PEP 484 feature; this article uses them interchangeably.

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

print(greet("Ana"))    # Hello, Ana — works, name is a str as hinted
print(greet(42))       # Hello, 42 — ALSO runs! Python does not enforce the hint

Function Annotations

Parameters are annotated with name: Type in the parameter list; the return type is annotated with -> Type after the closing parenthesis, before the colon. Any parameter or the return value can be left unannotated — annotation is entirely per-name, not all-or-nothing for a function.

Each parameter carries its own type hint; a default value (discount=0.0) can coexist with a hint — the hint comes first, then '=', then the default, as with quantity: int and unit_price: float above but with a default added for discount.

def calculate_price(quantity: int, unit_price: float, discount: float = 0.0) -> float:
    subtotal = quantity * unit_price
    return subtotal * (1 - discount)

print(calculate_price(3, 9.99, discount=0.1))  # 26.973

Variable Annotations

A variable (local, module-level, or a class attribute) can be annotated with name: Type or name: Type = value. Annotating without assigning is legal and only records the intended type in __annotations__ — it does NOT create the variable, so referencing it before assignment still raises NameError.

label: str with no '=' only records label's intended type in the module's __annotations__ dict; it never binds the name label to any object, which is a common point of confusion.

count: int = 0
label: str          # annotation only, no value yet — no binding created

print(__annotations__)  # {'count': <class 'int'>, 'label': <class 'str'>}

try:
    print(label)
except NameError as exc:
    print(f"error: {exc}")  # error: name 'label' is not defined

Annotating Class Attributes

Inside a class body, name: Type at the class level declares an intended instance-attribute type (used heavily by dataclasses, a related concept, and by type checkers), while self.name: Type = value inside __init__ annotates an instance attribute at assignment time.

The class-level x: int and y: int declare intended attribute types (useful to dataclasses and type checkers) without creating class-level values; actual assignment happens in __init__.

class Point:
    x: int
    y: int

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

    def distance_from_origin(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5

Runtime Versus Static Meaning

At runtime, annotations are stored (largely unevaluated at call time, depending on configuration — see below) in a function's __annotations__ dict and are otherwise inert; Python itself never raises a TypeError just because an argument doesn't match its hint. Statically — before the program even runs — a separate type-checking tool reads the same annotations and reports mismatches as errors, without executing any code.

__annotations__ is just a dict Python builds for introspection; nothing in the language automatically compares argument types to it, which is why add('x', 'y') runs without error despite violating the hints.

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

print(add.__annotations__)
# {'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

add("x", "y")  # runs fine at runtime, returns 'xy' — Python does not check the hints

The Role of Static Type Checkers

Third-party tools such as mypy, pyright, and pyre (all ecosystem tools, not part of the standard library) read a codebase's type hints and analyze it WITHOUT running it, flagging places where a value's inferred type doesn't match its declared/expected type. This catches a class of bugs before the code ever executes, which is the main practical payoff of writing type hints.

This is terminal output from running the third-party mypy tool against a file containing add('x', 'y') — mypy statically flags the mismatch that Python itself would silently allow at runtime.

$ mypy example.py
example.py:5: error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

Accessing Annotations Programmatically

Beyond __annotations__ on a function or class, the standard library's typing.get_type_hints() resolves annotations more robustly (handling forward references written as strings and certain evaluation edge cases) and is the recommended way to read hints at runtime when you actually need to.

get_type_hints() returns fully resolved type objects, correctly handling cases (like string forward references) that reading __annotations__ directly does not resolve.

from typing import get_type_hints

def scale(value: float, factor: float = 1.0) -> float:
    return value * factor

print(get_type_hints(scale))
# {'value': <class 'float'>, 'factor': <class 'float'>, 'return': <class 'float'>}

Forward References and Deferred Evaluation

Sometimes a hint needs to reference a name not yet defined (e.g. a class referencing its own type, or two classes referencing each other). Historically this required writing the type name as a string (a 'forward reference', e.g. "Node"). from __future__ import annotations (available since 3.7, and the effective default direction of the language) makes all annotations in a module be treated as strings automatically, avoiding this issue in most cases.

Without the __future__ import, referencing Node inside Node's own class body would fail at definition time in older evaluation modes because Node isn't fully defined yet; the future import defers evaluation of annotations, sidestepping the issue.

from __future__ import annotations

class Node:
    def __init__(self, value: int, next: Node | None = None) -> None:
        self.value = value
        self.next = next  # 'Node' can refer to itself; no string quoting needed

Basic Syntax Summary

This block collects the four common annotation positions side by side: annotated-with-value, annotation-only, function parameters/return, and class attribute.

age: int = 30                       # variable annotation with value
name: str                           # annotation only, no binding yet

def area(width: float, height: float) -> float:  # parameter + return annotations
    return width * height

class Circle:
    radius: float                    # class-level attribute annotation

    def __init__(self, radius: float) -> None:
        self.radius = radius

Key Takeaways

Type hints document intended types for variables, parameters, and return values; Python itself does not enforce them at runtime.

name: Type with no = only records an annotation — it does not create a binding, so the name is still undefined until assigned.

Function hints use param: Type in the signature and -> Type for the return value.

Real enforcement comes from third-party static type checkers like mypy or pyright, which analyze code without running it.

typing.get_type_hints() is the robust way to read resolved annotations at runtime, handling forward references correctly.

from __future__ import annotations treats all annotations as strings, simplifying self-referential and forward-referenced types.

def process(data: list) -> list: return sorted(data) process("not a list") # runs without error, then fails later or misbehaves silently from typing import Sequence def process(data: Sequence[int]) -> list[int]: return sorted(data) # run mypy/pyright to catch process("not a list") BEFORE runtime, since Python itself won't

Type hints alone don't stop the wrong type from being passed; catching this requires actually running a static type checker as part of the workflow, not just writing the hints.

class Config: debug: bool print(Config.debug) # AttributeError: type object 'Config' has no attribute 'debug' class Config: debug: bool = False # give it a value if you need a class-level default print(Config.debug) # False

debug: bool alone only records an annotation in Config.__annotations__; it never creates a class attribute named debug, so accessing Config.debug without assigning a value raises AttributeError.

def add(a: int, b: int) -> int: return a + b result: int = add(1, 2) result = "now a string" # Python allows this silently despite the earlier annotation

A variable annotation is not a runtime lock on that name's type; Python happily rebinds result to a str afterward — only a static type checker treats the annotation as something to enforce.

Does Python raise an error at runtime if a function is called with an argument that violates its type hint?

No — Python's runtime does not check type hints during ordinary calls; the function executes normally regardless of whether the argument matches its annotation.

What is the difference between count: int = 0 and count: int at module or class scope?

The first both annotates count as int AND binds it to the value 0; the second only records the annotation in __annotations__ and creates no binding, so referencing count afterward raises NameError (or AttributeError for a class attribute).

What actually enforces type hints in a typical Python workflow?

A separate, third-party static type checker (such as mypy or pyright) that analyzes the source code without running it; Python's own interpreter does not enforce hints.

Where are a function's type hints stored, and how can they be read reliably including forward references?

They're stored in the function's __annotations__ dict; typing.get_type_hints() is the recommended way to read them, since it resolves string forward references and other edge cases that reading __annotations__ directly does not handle.

Why might a class need to reference its own name inside a method's type hint, and how was this historically written?

A class like a linked-list Node often needs a method or attribute typed to reference Node itself (e.g. a 'next' pointer); historically this required writing the type as a quoted string, e.g. next: 'Node', since Node wasn't yet fully defined at that point in the class body.

What does from __future__ import annotations change about how annotations are evaluated?

It makes every annotation in that module be treated as an unevaluated string at definition time, which avoids forward-reference problems and generally reduces the runtime cost of annotations, though tools that need resolved types still use get_type_hints() to resolve them when needed.

Is annotating a function parameter with a type hint required for the function to run correctly?

No — annotations are entirely optional; a function with zero annotations behaves identically to one where every parameter and the return value are annotated, as far as the interpreter is concerned.

Write a fully type-hinted function average(values: list[float]) -> float that returns the arithmetic mean, raising ValueError on an empty list.

def average(values: list[float]) -> float: if not values: raise ValueError("cannot average an empty list") return sum(values) / len(values) print(average([1.0, 2.0, 3.0])) # 2.0

The parameter and return type are both annotated; the annotations are purely informational at runtime, and the actual empty-list guard is ordinary runtime logic, not something the hints provide by themselves.

What happens when this runs?

It prints 'value=hello' without any error — the annotation value: int does not stop label from being called with a str; f-strings stringify anything, so it runs to completion despite violating the hint.

Write a class Rectangle with class-level attribute annotations for width and height, plus a type-hinted area() method, and demonstrate that accessing an unset class-level annotation raises AttributeError.

class Rectangle: width: float height: float def __init__(self, width: float, height: float) -> None: self.width = width self.height = height def area(self) -> float: return self.width * self.height r = Rectangle(3.0, 4.0) print(r.area()) # 12.0 try: print(Rectangle.width) except AttributeError as exc: print(f"error: {exc}") width: float and height: float at class level only annotate, they don't create a class attribute — so Rectangle.width raises AttributeError until an instance actually assigns self.width in __init__.

NameError: name 'x' is not defined. x: int only records an annotation for x in __annotations__; it never binds x to any object, so referencing x before an actual assignment fails exactly as it would for any undefined name.

def label(value: int) -> str:
    return f"value={value}"

print(label("hello"))
x: int
print(x)