Part of Python for Beginners

Python Type Hints - Annotate Variables, Functions & Classes (Tutorial #29)

Sandy LaneSandy Lane

Video: Python Type Hints - Annotate Variables, Functions & Classes (Tutorial #29) by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Python Type Hints: list[int], Optional, Union, mypy

def f(x: int) -> str: — annotates parameters and return type. Hints don't affect runtime; tools like mypy check them statically. Modern Python: list[int] directly (no from typing import List).

Type hints are optional metadata — Python ignores them at runtime. But they're invaluable for catching bugs before they ship, for IDE autocomplete, and for self-documenting APIs.

Basic annotations

name: str = "Alice"
age: int = 25
height: float = 5.7
active: bool = True

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

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

def log_message(msg: str) -> None:
  print(f"[LOG] {msg}")

Syntax:

  • Variable: name: type = value.
  • Parameter: name: type in the function signature.
  • Return: -> type after the arglist.
  • No return → -> None.

These are hints — Python doesn't enforce them. add("hello", "world") runs and returns "helloworld", no error.

Why bother?

# Without hints, IDE doesn't know what you can call:
def process(item):
  return item.upper()    # works for str, fails for int

# With hints:
def process(item: str) -> str:
  return item.upper()    # IDE knows; mypy will catch errors

Benefits:

  • Static checkingmypy, pyright, IDE inspectors catch type errors before runtime.
  • Autocomplete — IDE knows what methods are available.
  • Documentation — signature shows the contract.
  • Refactoring confidence — changing types reveals all dependent code.

Container types

In modern Python (3.9+), use built-in containers directly:

def average(numbers: list[float]) -> float:
  return sum(numbers) / len(numbers)

def create_profile(name: str, age: int) -> dict[str, int | str]:
  return {"name": name, "age": age}

def get_pairs() -> list[tuple[str, int]]:
  return [("Alice", 30), ("Bob", 25)]

For older Python (3.5-3.8), import from typing:

from typing import List, Dict, Tuple

def average(numbers: List[float]) -> float:
  ...

The typing import still works in modern Python; the lowercase list[float] form is preferred.

Optional and None

from typing import Optional

def find_user(users: dict[str, int], name: str) -> Optional[int]:
  return users.get(name)

Optional[X] means "X or None." Equivalent to X | None.

In Python 3.10+:

def find_user(users: dict[str, int], name: str) -> int | None:
  return users.get(name)

The | syntax is cleaner and matches the runtime isinstance(x, int | None) form.

Union: multiple possible types

from typing import Union

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

Union[A, B] means "A or B." Modern syntax (3.10+):

def parse(value: str | int) -> str:
  return str(value)

Type aliases

UserProfile = dict[str, str | int]

def format_profile(profile: UserProfile) -> str:
  return f"{profile['name']}, age {profile['age']}"

When a complex type appears repeatedly, give it a name. Improves readability.

In modern Python, declare with TypeAlias to be explicit:

from typing import TypeAlias

UserProfile: TypeAlias = dict[str, str | int]

Callable

from typing import Callable

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
  return func(a, b)

result = apply(lambda x, y: x + y, 3, 5)

Callable[[arg_types], return_type]. Empty arg list Callable[[], int] means "no arguments, returns int."

Any: opt out

from typing import Any

def loose(x: Any) -> Any:
  return x.whatever()

Any disables type checking for that value. Use sparingly — it defeats the purpose. Reserve for genuinely dynamic interfaces.

Generic types

from typing import TypeVar

T = TypeVar("T")

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

x: int = first([1, 2, 3])     # T inferred as int
y: str = first(["a", "b"])     # T inferred as str

TypeVar("T") declares a generic. The function preserves the type — pass a list[int], get back an int.

Class type hints

class Point:
  x: float
  y: float

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

  def distance_to(self, other: "Point") -> float:
    return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5

Class attributes can be annotated at class level. Methods annotate self implicitly (no need to type it).

"Point" (string) is a forward reference — the class isn't fully defined when the method body is parsed. In Python 3.10+ with from __future__ import annotations, this is no longer needed.

mypy: the static type checker

pip install mypy
mypy your_module.py
def add(a: int, b: int) -> int:
  return a + b

add("hello", "world")    # mypy: error: Argument 1 has incompatible type "str"; expected "int"

mypy reads your code (without running it) and reports type mismatches. Add it to CI to catch type bugs before they ship.

Other tools

  • pyright — Microsoft's checker; faster than mypy; embedded in VS Code's Pylance.
  • pyre — Facebook's checker.
  • pytype — Google's checker.

All read the same hints; pick one.

Real-world patterns

from typing import Iterable, Iterator

def sum_squares(items: Iterable[int]) -> int:
  return sum(x ** 2 for x in items)

def evens(items: list[int]) -> Iterator[int]:
  return (x for x in items if x % 2 == 0)

Iterable[T] — anything you can iterate (list, tuple, generator, ...). Use as parameter type for max flexibility.

Iterator[T] — what generators return.

Literal types

from typing import Literal

def set_mode(mode: Literal["read", "write", "append"]) -> None:
  ...

set_mode("read")        # OK
set_mode("delete")      # mypy error

When the valid values are a small known set, use Literal. Better than a free-form string.

TypedDict

from typing import TypedDict

class User(TypedDict):
  name: str
  age: int
  email: str

u: User = {"name": "Alice", "age": 30, "email": "a@x.com"}

For dicts with a fixed schema, TypedDict documents the keys and types. Better than dict[str, Any] when the dict has structure.

When to add hints

  • Public APIs — yes. Document the contract.
  • Library code — yes. Users get autocomplete.
  • Complex types — yes. Hints often more readable than the code.
  • Throwaway scripts — no. Don't bother.

The middle ground: hint at boundaries (function signatures, class attributes), let local variable types be inferred.

Common stumbles

Old syntax in new code. List[int] works but list[int] is preferred (Python 3.9+).

Hints that don't match reality. def f(x: int) -> int: return str(x) — mypy catches it; your runtime tests should too.

Missing from __future__ import annotations. Forward references and | types are lazy-evaluated when this is at the top of the file.

Treating hints as runtime. def f(x: int): ... lets f("hello") succeed. Hints are not validation — use isinstance if you need runtime checks.

Over-annotating local variables. total: int = 0 is rarely worth the noise. Annotate when the type isn't obvious from context.

Using Any to silence mypy. Defeats the point. Fix the underlying issue, or use # type: ignore with a comment explaining why.

What's next

Lesson 30: dataclasses. @dataclass for boilerplate-free classes — __init__, __repr__, __eq__ generated for you.

Recap

name: type and def f(x: type) -> type: annotate. Built-in containers: list[int], dict[str, int], tuple[str, int]. Optional[X] or X | None for nullable. Union[A, B] or A | B for either. TypeVar for generics. Callable[[arg_types], return] for functions. Hints don't enforce at runtime — install mypy (or pyright) to check statically. Annotate at boundaries; let local types be inferred.

Next lesson: dataclasses.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.