Type hints in Python have grown from an experimental 2014 addition (PEP 484) into a near-universal baseline by 2026. The major libraries ship types: FastAPI is built on them, Pydantic v2 depends on them, SQLAlchemy 2.0 treats them as the primary API, mypy and Pyright analyze billions of lines of typed Python every day. The question is no longer whether to type your code; it's which of the 12+ typing PEPs released since 2018 are actually worth learning. This entry organizes the entire 2026 typing surface by adoption priority: the 5 features every Python developer should use, the 4 worth knowing for medium-size projects, and the 3 advanced tools that solve specific problems. Each one comes with example syntax, the PEP it was introduced in, and the Python version that ships it. Part of our Modern Python 2026 guide.
Key Takeaways
Essential in 2026: built-in generics (list[int]), union syntax (X | Y), X | None for optional, TypedDict, Protocol.
Worth knowing: NewType for distinct identifiers, TypeVar and Generic for reusable code, the typing_extensions backport, runtime_checkable Protocols.
Advanced: TypeGuard / TypeIs for runtime narrowing, ParamSpec for decorators, PEP 695 modern syntax (Python 3.12+).
Default to PEP 604 unions (X | Y) and X | None. The older Union[X, Y] and Optional[X] are legacy in 2026.
Types catch real bugs. Combined with mypy or Pyright, they catch ~30-40% of bugs at edit time instead of in production.
The 2026 Python typing toolkit organized by adoption priority. Pick from Tier 1 first; reach into Tier 2 and Tier 3 as the problem demands
Tier 1: The Five Essentials
If you only learn five typing features in 2026, learn these. Most modern Python code uses all of them.
1. Function signatures (PEP 484)
The foundation. Annotate parameters and return values; the syntax has been stable since Python 3.5.
The runtime ignores annotations; type checkers like mypy and Pyright read them and flag mismatches. Use them everywhere you'd otherwise leave a docstring describing argument types.
2. Built-in generics (PEP 585, Python 3.9+)
You can subscript built-in types directly. No more from typing import List, Dict.
# Modern (PEP 585, Python 3.9+):
def get_names() -> list[str]:
return ["Alice", "Bob"]
def get_scores() -> dict[str, int]:
return {"Alice": 95}
# Legacy form (still works, but unnecessary import):
from typing import List, Dict
def get_names() -> List[str]: ...
Cleaner code, less noise. The lowercase forms are now standard in modern Python.
3. Union syntax (PEP 604, Python 3.10+)
The X | Y form replaces Union[X, Y]. The X | None form replaces Optional[X].
# Modern (PEP 604):
def parse(value: int | str) -> int | None:
try:
return int(value)
except ValueError:
return None
# Legacy form:
from typing import Union, Optional
def parse(value: Union[int, str]) -> Optional[int]: ...
The PSF docs and typing reference both recommend the new syntax. Optional still works, but in 2026 it reads as legacy.
4. TypedDict (PEP 589, Python 3.8+)
For dict shapes with known keys. Standard for typing JSON, kwargs, and config.
from typing import TypedDict
class User(TypedDict):
id: int
name: str
email: str | None # optional value, but key must be present
def create_user(data: User) -> User:
return data
create_user({"id": 1, "name": "Alice", "email": None})
For mixed required-vs-optional keys, PEP 655 (Python 3.11+) added Required[X] and NotRequired[X]:
from typing import TypedDict, NotRequired
class User(TypedDict):
id: int
name: str
bio: NotRequired[str] # key may be absent entirely
When to choose TypedDict vs dataclass: TypedDict when the data arrives as a dict (JSON, kwargs, config); dataclass when you want a real class with methods. Covered in the data class alternatives comparison.
5. Protocol (PEP 544, Python 3.8+)
Structural subtyping for the type system. Any class with matching methods satisfies the Protocol, no inheritance required.
from typing import Protocol
class SupportsLen(Protocol):
def __len__(self) -> int: ...
def report_size(obj: SupportsLen) -> str:
return f"size = {len(obj)}"
report_size([1, 2, 3]) # OK, list has __len__
report_size("hello") # OK, str has __len__
report_size({1: 2}) # OK, dict has __len__
No class inherits from SupportsLen, yet all three calls type-check. Protocol gives you ABC-like interface checking without the inheritance coupling. Covered alongside ABCs in the ABC vs Protocol guide.
Tier 2: Worth Knowing
NewType for distinct identifiers
When you have two integer types that shouldn't mix (user IDs and order IDs), NewType makes the type checker treat them as different even though they're both int at runtime.
from typing import NewType
UserId = NewType('UserId', int)
OrderId = NewType('OrderId', int)
def get_user(uid: UserId) -> None: ...
uid = UserId(42)
oid = OrderId(99)
get_user(uid) # OK
get_user(oid) # mypy error: incompatible type
get_user(42) # mypy error: int is not UserId
Zero runtime cost (NewType returns its argument unchanged). Useful for domains where mixing identifier types would be a real bug.
TypeVar and Generic
For functions and classes that work with many types but preserve the type relationship.
from typing import TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T:
return items[0]
# Type checker infers T:
x: int = first([1, 2, 3]) # T = int, OK
y: str = first(["a", "b"]) # T = str, OK
z: int = first(["a", "b"]) # error: T = str, not int
Pair with Generic[T] for typed container classes. In Python 3.12+, PEP 695 removes the boilerplate (covered below).
typing_extensions backport
The typing_extensions PyPI package backports newer typing PEPs to older Python versions. If you support Python 3.9 but want features from 3.11 or 3.12, install it and import the new features:
pip install typing-extensions
from typing_extensions import NotRequired, TypeIs # works on 3.9
class User(TypedDict):
id: int
bio: NotRequired[str]
Standard practice for libraries that want to use modern typing while supporting older interpreters.
runtime_checkable Protocols
By default, isinstance(obj, MyProtocol) raises TypeError. Decorate the Protocol with @runtime_checkable to enable isinstance checks.
Caveat: @runtime_checkable only checks method NAMES exist, not signatures. A class with a misnamed parameter still passes. Use static type-checking when possible; reserve runtime_checkable for guards where loose matching is acceptable.
Tier 3: Advanced Tools
TypeGuard and TypeIs (PEP 647, PEP 742)
When you write a runtime check that narrows a type, the type checker doesn't know about it by default. TypeGuard (Python 3.10+) and TypeIs (Python 3.13+) tell the checker what your function confirms.
from typing import TypeIs
def is_str_list(values: list[object]) -> TypeIs[list[str]]:
return all(isinstance(v, str) for v in values)
def process(items: list[object]) -> None:
if is_str_list(items):
# Type checker now knows items is list[str]
print(", ".join(items)) # No type error
else:
print("Not all strings")
TypeIs is generally preferred over TypeGuard because it also narrows in the else branch. Use it for custom predicates that return bool but tell the checker more.
ParamSpec for decorators (PEP 612, Python 3.10+)
Decorators often wrap a function and forward all its arguments. Before PEP 612, there was no way to express "this decorator preserves the wrapped function's signature exactly". Now there is:
from typing import Callable, ParamSpec, TypeVar
from functools import wraps
P = ParamSpec('P')
R = TypeVar('R')
def log_calls(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_calls
def greet(name: str, count: int = 1) -> str:
return f"Hello {name}" * count
greet("Alice", count=2) # Type checker knows: str, int -> str
greet(42) # Type checker error: int not str
Without ParamSpec, the wrapped function would lose its parameter types. With it, the decorator is fully transparent to the type checker.
PEP 695 modern syntax (Python 3.12+)
The cleanest generic syntax Python has ever shipped. Type parameters go in brackets directly after the function or class name; no separate TypeVar assignment, no inheriting from Generic.
# Legacy (still works):
from typing import TypeVar, Generic
T = TypeVar('T')
class Stack(Generic[T]):
def push(self, item: T) -> None: ...
# Modern PEP 695 (Python 3.12+):
class Stack[T]:
def push(self, item: T) -> None: ...
# Type aliases also got the new syntax:
type Vector[T] = list[T] # was: Vector: TypeAlias = list[T]
For new code targeting Python 3.12+, prefer the PEP 695 syntax. It's shorter, scoped locally, and integrates cleanly with future typing PEPs.
Deferred Annotations: PEP 563 and PEP 749
By default, Python evaluates annotations at function-definition time. For forward references (a class referencing itself or a class defined later in the file), this fails:
# Naive version raises NameError:
class Node:
def __init__(self, value: int, next: Node = None): # Node undefined here
...
The historical fix used string annotations or from __future__ import annotations (PEP 563), which makes ALL annotations lazy strings:
from __future__ import annotations
class Node:
def __init__(self, value: int, next: Node | None = None):
self.value = value
self.next = next
This worked but broke runtime introspection of annotations (Pydantic, dataclasses, FastAPI). PEP 749 (Python 3.14+) replaces this with a cleaner mechanism: annotations are stored as both evaluated values AND as deferred form, available via __annotations__ and typing.get_type_hints(). Modern code targeting 3.14+ can drop the from __future__ import annotations import and rely on PEP 749's machinery.
For libraries supporting older Python, from __future__ import annotations remains the right call. For new applications on 3.14+, you can usually skip it.
Stub Files (.pyi) for Untyped Libraries
When you depend on a library that doesn't ship type hints, you have three options:
Hope someone publishes types-LIBRARY on PyPI (the typeshed project ships many of these);
Write a local stub file (a .pyi file mirroring the library's API with annotations);
Add # type: ignore comments at call sites and move on.
A stub file looks exactly like a Python file but contains only signatures, no implementations:
The type checker reads the stub instead of the actual implementation when checking your code. Place stubs in a directory listed under mypy_path or stubPath in your checker config. The official typeshed repository is a community-maintained stub library for the standard library and many third-party packages.
TYPE_CHECKING for Circular Imports
The typing.TYPE_CHECKING constant is False at runtime and True only during type checking. Use it to import names that would create circular dependencies if imported normally:
# orders.py
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from users import User # only imported during type checking
def create_order(user: "User", amount: float) -> int:
return amount * user.discount
The string "User" annotation is needed because at runtime User isn't imported. With from __future__ import annotations or on Python 3.14+ with PEP 749, the quotes can be dropped. Use TYPE_CHECKING sparingly; if you find yourself reaching for it everywhere, the module structure is probably too tightly coupled.
What to Skip in 2026
Not every typing feature pays back the complexity. A few to use sparingly or avoid:
typing.Any: opts out of type checking. Useful as an escape hatch but a smell when used widely. If you find yourself reaching for Any, consider object (read-only) or a Protocol instead.
Heavy variance annotations (covariant=True, contravariant=True): legitimate in library code that defines generic containers, overkill in application code.
TYPE_CHECKING for everything: useful for breaking circular imports in type stubs, but easy to overuse. Most projects need it on 1-2 modules.
Self in tiny one-method classes: Self (PEP 673, Python 3.11+) is great for fluent builders and chained methods, less useful when a class has one method that returns the instance.
The typing module imports are legacy in 2026 for these common cases.
2. Annotating self or cls
# Don't do this:
class Foo:
def bar(self: "Foo") -> None: ...
# Just do this:
class Foo:
def bar(self) -> None: ...
The type checker already knows self is an instance of the containing class.
3. Using list instead of Sequence in function parameters
If your function only reads from a list-like argument, accept the more general Sequence[T]. Callers can then pass tuples, deques, or any other read-only sequence.
from typing import Sequence
def total(numbers: Sequence[int]) -> int:
return sum(numbers)
total([1, 2, 3]) # list works
total((1, 2, 3)) # tuple works
total(range(10)) # range works
4. Forgetting type narrowing patterns
def length(value: str | None) -> int:
return len(value) # mypy error: value might be None
# Fix:
def length(value: str | None) -> int:
if value is None:
return 0
return len(value) # OK, narrowed to str
Type checkers narrow types after if x is None, isinstance, and assert. Use these checks to convince the checker that a value is safe to use.
5. Adding types without running a type checker
Type hints without mypy or Pyright running on the code are just decoration. Set up at least one checker in CI; otherwise the hints drift out of sync with reality and become misleading. Covered in the type checker comparison.
Real-World Example: A Typed API Handler
A representative production pattern using multiple typing features:
from typing import TypedDict, NotRequired, Protocol
from dataclasses import dataclass
class CreateUserPayload(TypedDict):
email: str
name: str
age: NotRequired[int]
class Database(Protocol):
def insert(self, table: str, data: dict[str, object]) -> int: ...
@dataclass(frozen=True)
class User:
id: int
email: str
name: str
age: int | None
def create_user(payload: CreateUserPayload, db: Database) -> User:
data = {"email": payload["email"], "name": payload["name"]}
if "age" in payload:
data["age"] = payload["age"]
new_id = db.insert("users", data)
return User(id=new_id, email=payload["email"], name=payload["name"],
age=payload.get("age"))
Five typing features in 30 lines: TypedDict for the input shape, NotRequired for the optional field, Protocol for the database dependency, dataclass with type annotations, and PEP 604 union syntax for the optional age. Type checkers verify the whole pipeline without any runtime cost.
Frequently Asked Questions
Are Python type hints required in 2026?
Not required by the runtime; Python still executes untyped code without any warnings. But by 2026 type hints are effectively standard in serious Python codebases: most popular libraries publish type stubs, modern IDEs depend on them for autocomplete and error highlighting, and the major web frameworks (FastAPI, Pydantic, SQLAlchemy 2.0) treat them as the primary API. Skipping types is still legal Python, but it puts you outside the mainstream tooling. For new code, default to typed; for legacy code, add types as you touch each module.
What is the difference between Optional[X] and X | None in Python?
They mean exactly the same thing: a value that is either an X or None. Optional[X] is the older form from PEP 484 (Python 3.5+), and X | None is the newer form from PEP 604 (Python 3.10+). The newer X | None is preferred in modern code because it reads naturally, doesn't require importing Optional from typing, and aligns with the broader union syntax (X | Y for any types). The takeaway: in 2026, write X | None unless you target Python 3.9 or earlier.
When should I use TypedDict vs a dataclass in Python?
Use TypedDict when you're working with dict shapes that already exist: JSON from an API, kwargs forwarded between functions, config dicts loaded from YAML. The shape is dict-typed but the keys are known. Use dataclass when you want a real Python class with methods, behavior, and explicit construction. The line: if the data comes in as a dict and stays as a dict, type it with TypedDict; if you'd benefit from methods, defaults, or __repr__, use a dataclass. They're complementary, not competing.
What is PEP 695 and should I use the new type syntax?
PEP 695 (Python 3.12+) added a cleaner syntax for generics and type aliases. Instead of TypeVar('T') plus a class inheriting from Generic[T], you write class Container[T]: directly. Instead of T = TypeVar('T') ... MyList: TypeAlias = list[T], you write type MyList[T] = list[T]. The new syntax is shorter, scoped locally to the function or class, and integrates with PEP 749 lazy annotations. Use it for all new code targeting Python 3.12+; the old syntax still works but reads as legacy.
What is Protocol in Python type hints?
Protocol (from PEP 544, Python 3.8+) defines an interface as a set of method signatures. Any class with matching methods automatically satisfies the Protocol, without explicit inheritance. This is structural subtyping (duck typing for type checkers). The classic use case: a function parameter that needs an object supporting some method, but the function shouldn't care which specific class. Protocol gives the static checker enough information to validate that callers pass compatible types. For runtime isinstance checks, decorate with @runtime_checkable, but be aware it only checks method names, not signatures.
The Bottom Line: Adopt by Tier, Not by Hype
Modern Python type hints are a layered toolkit. The five essentials (function signatures, built-in generics, union syntax, TypedDict, Protocol) belong in every typed codebase. The four worth-knowing tools (NewType, TypeVar, typing_extensions, runtime_checkable) come up as projects grow. The three advanced features (TypeGuard / TypeIs, ParamSpec, PEP 695 syntax) solve specific problems and aren't needed for most code. Type checkers (mypy, Pyright) verify the annotations at edit time and catch real bugs; covered next in our type checker comparison. Or browse the full Modern Python 2026 guide.
Make Type Hints Reflex Through Practice
CodeGym's Python track puts type hints into 800+ hands-on tasks across 62 levels: typed function signatures, TypedDict for API payloads, Protocol for clean interfaces, generics, and PEP 695 syntax. AI validators catch the common traps (missing return type, wrong narrowing, forgotten Optional); AI mentors explain why mypy flagged a particular annotation. First level free; full plan on the pricing page.
GO TO FULL VERSION