Skip to content
C Codeloom
Python

Introduction to Python Type Hints

A practical introduction to Python type hints — basic syntax, common collection types, Optional and Union, type-checking with mypy, and how hints improve code without changing runtime behaviour.

·5 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • What type hints are and what they are not
  • Annotating variables, parameters, and return values
  • Hinting lists, dicts, and other collections
  • Using Optional and Union for nullable values
  • Catching bugs early with mypy

Prerequisites

  • Basic Python familiarity

Python is dynamically typed and will stay that way. Type hints don’t change that. What they do is let you write down, in code, what you intend each variable and function to hold — and then let tools check whether the rest of your code agrees. The result is fewer bugs, faster onboarding, and better autocomplete in your editor.

What Type Hints Are Not

Type hints are not enforced at runtime. Writing def add(a: int, b: int) -> int does not stop someone from calling add("hello", "world") — Python will happily concatenate the strings. The check happens when you run a static analyser like mypy, pyright, or pyre, or when your editor surfaces a warning.

That detachment is a feature. It means you can add hints gradually to a codebase without breaking anything.

The Basic Syntax

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

The name: str is a parameter annotation. The -> str after the parameter list is the return annotation. You can also annotate plain variables.

score: int = 0
username: str = "ada"

The annotation goes after the variable name and before the assignment.

Hinting Collections

In modern Python (3.9+) you can use the built-in collection types directly as generics.

def total(values: list[float]) -> float:
    return sum(values)

def lookup_user(users: dict[int, str], user_id: int) -> str:
    return users[user_id]

In older Python you would import equivalents from typingList, Dict, Tuple, Set. The new style is cleaner, but both still work.

For functions that don’t care about the exact collection type, prefer the abstract types from collections.abc or typing.

from collections.abc import Iterable

def biggest(items: Iterable[int]) -> int:
    return max(items)

Iterable[int] accepts a list, a tuple, a generator, or anything else you can loop over. Using the most general type your function actually needs makes it more reusable.

Optional and Union

A value that is sometimes None should be hinted with Optional or the newer X | None syntax.

def find_user(user_id: int) -> str | None:
    if user_id < 0:
        return None
    return "ada"

str | None reads as “string or None.” The older spelling Optional[str] means exactly the same thing. The | syntax also extends to general unions.

def parse_id(raw: str | int) -> int:
    return int(raw)

Any, the Escape Hatch

Any disables type checking for whatever it touches. It is sometimes necessary — for example, when a value really could be anything — but reaching for it too quickly defeats the purpose of typing.

from typing import Any

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

Use Any deliberately, and treat it as a marker that you owe future you a more precise type.

Type Aliases

For repeated complex types, create an alias.

UserId = int
UserMap = dict[UserId, str]

def display(users: UserMap) -> None:
    for uid, name in users.items():
        print(uid, name)

The code is shorter and the intent is clearer than spelling out dict[int, str] everywhere.

Generic Functions and TypeVar

When you want a function to work with any type but preserve that type, use TypeVar.

from typing import TypeVar

T = TypeVar("T")

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

Now first([1, 2, 3]) is inferred as int, and first(["a", "b"]) as str. Without TypeVar you would have to fall back to Any and lose the connection between input and output.

Class Attribute Hints

Inside classes, type hints work the same way and play nicely with dataclasses.

from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    email: str | None = None

The dataclass decorator uses these annotations to generate __init__.

Running mypy

Install mypy with pip install mypy, then run it on your file or package.

mypy app.py

Mypy reports type errors without running your code. A simple bug like passing a list where a dict is expected will be caught immediately. Many teams add mypy to their CI pipeline so type errors block merges.

You can configure strictness in mypy.ini or pyproject.toml. New projects often start with --strict. Existing projects usually adopt mypy gradually, file by file.

Common Pitfalls

A hint that doesn’t match reality is worse than no hint. If you change a function’s behaviour, update its annotations. Treat mypy errors like compiler errors: fix them as they appear.

Forward references — referring to a class before it’s defined — need to be in quotes, or you can add from __future__ import annotations at the top of the file to make all annotations lazy strings.

from __future__ import annotations

class Tree:
    def add_child(self, child: Tree) -> None:
        ...

Wrapping Up

Type hints are optional, ignorable, and quietly powerful. Start by annotating your function signatures, run mypy on your most-changed files, and let the warnings guide you. Within a few weeks you will be catching bugs in your editor that used to surface only in production.