Python: Type Hints, Annotations, Union Types (X | None), Generics

Python type hints let you write down what types a function expects and returns. This guide compares the annotation styles so you know which one to reach for: the modern X | None union syntax, container generics like list[int], TypeVar for generic functions, Protocol for structural subtyping, and the journey from the old typing.Optional to today’s native Python 3.10+ syntax.

“Type hints are documentation that the computer can check.”

Guido van Rossum

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Advanced | Reading Time: 16 minutes

Python is dynamically typed. A name that holds a string on one line can hold an integer on the next, and Python will not complain. That freedom is wonderful when you are prototyping and painful when you are six months into a large codebase and cannot remember whether process_order wants a dict or an object. Type hints fix that. You annotate what types a function expects and returns, and Python runs exactly as before. They are optional, you can add them one function at a time, and tools like mypy read them to catch bugs before your code ever runs.

Here is the everyday picture. Type hints are the labels on the bins in a hardware store. The screws still work if every bin is blank, but the moment someone labels them “M4 x 20mm” and “wood, 3 inch”, nobody grabs the wrong one by accident. A type hint is that label on a function: it tells the next person, who is often your future self, exactly what goes in and what comes out.

One thing before we start. This post uses modern Python 3.10+ syntax throughout. The old Optional[X] and Union[X, Y] forms still appear, but only so you can read older code. In anything new, write X | None and X | Y. The whole comparison below boils down to one idea: there is almost always a newer, shorter way to spell the same hint, and that is the one to use.

Syntax EvolutionPython Type HintsBasic Typesint, str, float,bool, bytes, NoneContainer Typeslist[int], dict[str, int]tuple[str, …], set[int]Union TypesX | Y (3.10+)Optional[X] = X | NoneCallable TypesCallable[[int, str], bool]GenericsTypeVar(‘T’)Generic[T]ProtocolStructural subtypingDuck typing formalizedModern (3.12+)type Point =tuple[int, int]type alias statement3.5: typing moduleList[int]3.9: built-ingenerics list[int]3.10: union syntaxint | str3.12: type statementtype Vector =list[float]Python Type Hints: The Annotation Family and How the Syntax Evolved

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

The diagram lays out the Python type hint family side by side: basic types (int, str, float), container generics (list[int], dict[str, int]), union types (str | None), callable signatures, and structural protocols. The top row also shows how the syntax evolved across versions, from the old List[int] in Python 3.5 to the type alias statement in 3.12. Each box adds a little more precision to your function signatures, which helps both humans and tools like mypy. You do not need to annotate everything. Start with function parameters and return types, because that is where hints pay off the most.

Pick Your Annotation Style in 30 Seconds

Most of the confusion around python type hints is not about whether to use them. It is about which spelling to use, because Python has accumulated several over the years. This table is the decision guide. Find what you are trying to express in the left column, write the form in the “Use this now” column, and never type the old form in new code again.

What you want to sayUse this now (3.10+)Old form (read only)Since
A list of intslist[int]typing.List[int]3.9
A dict from str to intdict[str, int]typing.Dict[str, int]3.9
This or that typeint | strtyping.Union[int, str]3.10
A value or Nonestr | Nonetyping.Optional[str]3.10
A function valueCallable[[int], bool]same (from collections.abc)3.9
Works with any typeTypeVar or [T] syntaxtyping.TypeVar3.5 / 3.12
Has the right methodstyping.Protocolnothing equivalent3.8
A named type aliastype Vector = list[float]Vector = list[float]3.12

If you remember only one rule from this whole post, make it this: prefer the built-in lowercase types (list, dict, tuple, set) and the pipe (|) for unions. You almost never need to import from typing anymore. The exceptions are the named tools that still live there, like TypeVar and Protocol. The rest of this guide walks through each row of the table with tested code.

Basic Type Hints

Start at the smallest unit: one function, one variable. The syntax is a colon after a parameter name for its type, and an arrow before the return type. That is the whole grammar. Think of the colon as a name badge at a conference: name: str pins a badge on the parameter saying what it is supposed to be, so everyone who meets it later knows at a glance. In the example below we annotate a small greeting function and try it on an employee named Niranjan, first correctly, then with the arguments deliberately mixed up.

📄 basic_hints.py: function and variable annotations

# Function annotations: parameters and return type
def greet(name: str, age: int) -> str:
    return f"Hello {name}, you are {age} years old"

# Variable annotations
employee_name: str = "Rahul"
salary: float = 75000.0
is_active: bool = True

# Python does NOT enforce these at runtime!
result = greet("Niranjan", 26)   # Works fine
result = greet(42, "oops")       # Also runs! (but mypy would catch it)
print(result)

▶ Output

Hello 42, you are oops years old

What happened here: Python ran both calls without a single complaint, including greet(42, "oops"), which passes an int where the name belongs and a string where the age belongs. That is the part beginners stumble on. Type hints are not enforced at runtime. They are notes stapled to your code for tools like mypy, the autocomplete in your IDE (Integrated Development Environment), and the next human who reads it. The interpreter itself ignores them. Run mypy on this exact file and it reports the real story:

▶ Output: mypy basic_hints.py

basic_hints.py:12: error: Argument 1 to "greet" has incompatible type "int"; expected "str"  [arg-type]
basic_hints.py:12: error: Argument 2 to "greet" has incompatible type "str"; expected "int"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

So the hint did its job. Python stayed quiet, but mypy caught the bug before the code shipped. That split, silent at runtime and strict under a checker, is the single most important idea in this post.

Union Types and None: The Modern Syntax

A union says “this value is one of several types”. The classic case is a lookup that might find nothing and hand back None. Before Python 3.10 you wrote Optional[dict] or Union[int, str] and imported them from typing. Now you just write a pipe between the types, the same | you already know from bitwise OR. Think of it like the “or” on a form: “phone or email”, either one is fine.

📄 union_types.py: X | Y replaces Union[X, Y] since Python 3.10

# Modern syntax (3.10+), use this
def find_user(user_id: int) -> dict | None:
    users = {1: {"name": "Viraj", "age": 29}}
    return users.get(user_id)

# Accepts multiple types
def process(value: int | float | str) -> str:
    return str(value)

# Old syntax (pre-3.10), only for reading legacy code:
# from typing import Optional, Union
# def find_user(user_id: int) -> Optional[dict]:  # same as dict | None
# def process(value: Union[int, float, str]) -> str:

result = find_user(1)
print(result)

result = find_user(99)
print(result)

▶ Output

{'name': 'Viraj', 'age': 29}
None

What happened here: The return type dict | None is honest. find_user(1) found a user named Viraj and returned his record as a dict, while find_user(99) found nobody and returned None. Because the signature spells out both outcomes, mypy forces every caller to handle the None case before treating the result as a dict. That one habit kills a huge share of AttributeError: 'NoneType' object has no attribute ... crashes. The commented block shows the old Optional and Union forms so you can recognize them in legacy code, but you would never type them in something new.

Generics with TypeVar and Generic

Sometimes a function should work with any type but still keep track of which one it got. A “give me the first item” helper should return a string when you hand it a list of strings, and an int when you hand it a list of ints. A TypeVar is the placeholder that carries that link. Picture a coat check ticket: you hand over a coat, you get a numbered stub, and the same number gets you the same coat back. T is that ticket number. Whatever type goes in is the type that comes out.

📄 generics.py: write functions that work with any type

from typing import TypeVar

T = TypeVar("T")

def first(items: list[T]) -> T | None:
    """Return first item or None if empty."""
    return items[0] if items else None

# Works with any list type, and mypy tracks the return type
names: str | None = first(["Aditi", "Prathamesh", "Vinay"])
numbers: int | None = first([10, 20, 30])
empty: None = first([])

print(names)
print(numbers)
print(empty)

▶ Output

Aditi
10
None

What happened here: One function, three different element types, and the return type stayed correct each time. Pass a list[str] and mypy knows first(...) returns str | None. Pass a list[int] and it knows the result is int | None. Without the TypeVar you would have to annotate the return as object or Any, which throws away exactly the information you wanted. On Python 3.12 and newer you can skip the separate TypeVar line and write def first[T](items: list[T]) -> T | None: directly, but the explicit TypeVar shown here still works everywhere and is what you will see in most existing code.

Protocol: Duck Typing, Formalized

Python has always followed the “if it walks like a duck and quacks like a duck, it is a duck” rule. You can pass any object to a function as long as it has the methods the function calls. The catch was that type checkers could not see that rule, so they could not help you. Protocol writes the rule down. You declare “anything with a draw() method that returns a str” and mypy will accept any object that fits, no shared base class required. It is like a job posting that lists required skills instead of demanding a specific diploma. If you can do the work, you are hired.

📄 protocol_example.py: structural subtyping without inheritance

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> str: ...

class Circle:
    def draw(self) -> str:
        return "Drawing a circle"

class Square:
    def draw(self) -> str:
        return "Drawing a square"

def render(shape: Drawable) -> None:
    """Accepts ANY object with a draw() -> str method."""
    print(shape.draw())

# Neither Circle nor Square inherits from Drawable!
# Protocol checks structure, not inheritance.
render(Circle())
render(Square())

▶ Output

Drawing a circle
Drawing a square

What happened here: Notice that neither Circle nor Square inherits from Drawable. They never mention it. Yet render() accepts both, and mypy is happy, because each class has a draw() method that returns a str, which is exactly what the Drawable protocol asks for. This is structural subtyping: you match a type by shape, not by ancestry. It is the cleaner choice when you do not own the classes you want to accept, for example objects from a third-party library that you cannot make inherit from your base class.

Container Type Hints

A bare list hint tells you it is a list but not what is inside. A list[int] tells you both. The thing in the square brackets is the type of the contents, like a label on a storage box that says not just “box” but “box of cables”. Since Python 3.9 you use the lowercase built-in names directly, with no import. Here are the four containers you will reach for daily, plus the Callable hint for passing functions around.

📄 containers.py: list, dict, tuple, set with type parameters

# Since 3.9: use built-in types directly (no typing import needed)
scores: list[int] = [95, 88, 72]
user_ages: dict[str, int] = {"Anvi": 28, "Anvay": 25}
coordinates: tuple[float, float] = (19.076, 72.877)
unique_tags: set[str] = {"python", "tutorial", "testing"}

# Nested types
matrix: list[list[int]] = [[1, 2], [3, 4]]
config: dict[str, list[str]] = {
    "allowed_hosts": ["localhost", "example.com"],
    "plugins": ["auth", "logging"],
}

# Callable type: a function taking two ints and returning an int
from collections.abc 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, 10, 20)
print(result)

▶ Output

30

What happened here: Each annotation describes the shape of the data, not just its outer type. tuple[float, float] means exactly two floats, a coordinate pair, while tuple[int, ...] would mean any number of ints. The nested list[list[int]] and dict[str, list[str]] read top to bottom: a list of lists of ints, a dict from str keys to lists of str. The Callable[[int, int], int] hint is the odd one out: the first bracket holds the argument types and the value after the comma is the return type. Import Callable from collections.abc, not from typing, which is the modern home for it.

Checking Types with mypy

Python type hints are just comments until a checker reads them. mypy is that checker, and it is the one to learn first. Install it, point it at a file, and it reports type mismatches without running a line of your code. Think of it as a spellchecker for types: it does not change your document, it just underlines the parts that do not fit.

📄 Terminal: install and run mypy

pip install mypy
mypy --version
mypy my_module.py --strict

▶ Output (mypy 1.18.1 on a clean file)

mypy 1.18.1 (compiled: yes)
Success: no issues found in 1 source file

What happened here: A clean file gives you Success: no issues found, which is the message you want before every commit. When mypy does find a problem, it prints the file, the line number, and the exact mismatch, the same way it flagged the mismatched greet() call in the very first example of this post. The --strict flag turns on every check at once, including a warning for any function that is missing hints. Start without it while you are adding hints to old code, then turn it on once a module is fully annotated to keep it that way.

Common Mistakes

Two errors show up again and again when reviewers scan python type hints. Both come from carrying old habits into new Python.

❌ Mistake 1: Using the old typing imports when built-ins work

# BAD (3.9+): do not import from typing for built-in generics
from typing import List, Dict, Optional

# GOOD (3.9+): use built-in types directly
scores: list[int] = [1, 2, 3]
data: dict[str, int] = {"a": 1}
value: int | None = None  # 3.10+

Why: the capitalized List, Dict, and Optional from typing were necessary years ago, before the built-in types could be subscripted. Since Python 3.9 the lowercase built-ins do the job with no import, and since 3.10 the pipe replaces Optional and Union. Reaching for the typing versions in new code is not wrong exactly, it just marks the code as dated and adds imports you do not need.

❌ Mistake 2: Thinking type hints are enforced at runtime

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

# Python will happily run this, no runtime error
print(add("hello", " world"))
# Use mypy or Pydantic for actual enforcement

▶ Output

hello world

The hint said int, you passed two strings, and Python concatenated them into hello world without a peep. The hint is documentation that a checker enforces, not a runtime guard. If you need the value validated while the program runs, reach for Pydantic (covered in the Pydantic tutorial), which actually rejects bad data at the door.

Practice Exercises

  1. Exercise 1: Write an average function that takes a list[int] and returns a float. Hint the empty-list case so it returns float | None.
  2. Exercise 2: Build a tiny generic cache class using a TypeVar, where get(key) returns the stored value or None. Use dict[str, T] and T | None, no Optional or Union imports.
  3. Exercise 3: Define a Comparable Protocol with a __lt__ method, write a smallest function that accepts any list of comparable items, and make the whole file pass mypy --strict.

Conclusion

You now have the full modern toolkit: lowercase built-ins like list[int] and dict[str, int] for containers, the pipe for unions and X | None for maybe-missing values, TypeVar when the output type must follow the input type, and Protocol when you care about what an object can do rather than what it inherits from. The one rule to carry forward: Python never enforces any of this at runtime, so a checker like mypy is not optional extra credit, it is the other half of the feature. Wire it into your editor and your CI and the hints start paying rent immediately.

Next up we put these annotations to work in dataclasses, where python type hints stop being documentation and start generating your __init__ for you. And if you want to jump around or catch up on earlier chapters, the full index lives at the Python + AI/ML tutorial series home.

Frequently Asked Questions

Are Python type hints enforced at runtime?

No. Python type hints are metadata for tools like mypy, IDEs, and documentation generators. Python ignores them at runtime, so a wrong type will not raise an error on its own. Use Pydantic or beartype if you need runtime type checking.

What is the difference between X | None and Optional[X]?

They mean exactly the same thing. X | None is the modern syntax (Python 3.10+). Optional[X] is the old form from the typing module. Write X | None in new code, because it is clearer and needs no import.

What is Protocol in Python typing?

Protocol enables structural subtyping, which is duck typing with type checking. A class satisfies a Protocol if it has the required methods and attributes, with no inheritance needed. It formalizes Python’s ‘if it quacks like a duck’ philosophy so that mypy can verify it.

Should I add type hints to every function?

Start with public APIs, function signatures, and complex code. Skip hints for trivial one-liners and private helpers where the types are obvious. Gradual typing means you can add them one function at a time.

What is mypy and how do I use it?

mypy is a static type checker for Python. Install it with pip install mypy and run it with mypy your_file.py. It reads your type hints and reports errors without running the code. Use --strict for the most thorough checking.

Interview Questions on Python Type Hints

The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.

Q: You run mypy for the first time on a 50,000-line legacy codebase and it reports 900 errors. How do you adopt type hints without freezing feature work?

Adopt gradually, module by module. Add a mypy config with relaxed defaults plus ignore_missing_imports for untyped third-party packages, then fully annotate a few high-value modules and enable --strict only for those via per-module overrides. Require hints on all new and modified functions in code review, and gate CI so the total error count can only go down. This ratchet approach gets you real coverage in weeks without a big-bang rewrite.

Q: A function is annotated -> dict | None, yet production still crashes with AttributeError: ‘NoneType’ object has no attribute ‘get’. The team says “but we added type hints”. What went wrong?

Type hints do nothing at runtime, so the annotation alone cannot prevent the crash. The team is almost certainly not running mypy (or is ignoring its output), because mypy would have refused to let a caller call .get() on a dict | None value without first narrowing it with a check like if result is not None:. The fix is two-part: add the narrowing check at each call site, and wire mypy into CI so this class of bug fails the build instead of failing in production.

Q: Why is a TypeVar better than Any for a function like def first(items): return items[0]?

Any switches type checking off: whatever comes back is accepted everywhere, so mypy cannot catch misuse of the result. A TypeVar keeps checking on and preserves the link between input and output: pass a list[str] and the return is known to be str | None, pass a list[int] and it is int | None. In short, Any throws information away while a TypeVar carries it through the call.

Q: When would you choose Protocol over an abstract base class (ABC)?

Choose Protocol when you do not own the classes you need to accept, such as objects from a third-party library, because a Protocol matches by structure and requires no inheritance. Choose an ABC when you control the class hierarchy and want to share default implementations or force subclasses to register explicitly. Also note that @runtime_checkable Protocols only verify method names in isinstance() checks, not signatures, so they are weaker for runtime validation than they look.

Q: What is the difference between tuple[int], tuple[int, …], and list[int]?

tuple[int] means a tuple of exactly one int, which surprises many candidates. tuple[int, ...] means a variable-length tuple where every element is an int, and it is the closest tuple equivalent to list[int]. list[int] is a mutable list of ints of any length. Interviewers use this to test whether you know tuples are fixed-shape by default in the type system.

Q: What did PEP 695 in Python 3.12 change about writing generics?

It added dedicated syntax so you no longer need to declare a TypeVar separately: you write def first[T](items: list[T]) -> T | None: for functions, class Stack[T]: for classes, and type Vector = list[float] for aliases. The scoping is also cleaner, since T exists only inside the function or class that declares it. The older explicit TypeVar form remains valid and is still what you will read in most existing codebases.

Want more? the official Python documentation documents everything this post could not fit.

Previous: Python Mocking & Patching with unittest.mock

Next: Python: Dataclasses, Modern Data Containers

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *