No. Type hints are annotations, not constraints — the interpreter
stores them (in __annotations__) but never checks them. You can pass
a str where an int is annotated and Python runs it happily; enforcement
is the job of an external static type checker like mypy or pyright.
def double(n: int) -> int:
return n * 2
double("ab") # runs fine -> "abab", no TypeError
double.__annotations__ # {'n': <class 'int'>, 'return': <class 'int'>}
If you want runtime validation you opt in explicitly — e.g. pydantic,
typing.get_type_hints, or manual isinstance checks. Rule of thumb: hints
document and enable tooling; they are not a runtime guard.
Union[A, B] means "A or B". Optional[X] is just shorthand for
Union[X, None] — a value that may be X or None. It does not
mean "optional argument"; it means "could be None". Since Python 3.10 you
can write unions with the | operator instead of importing from typing.
from typing import Optional, Union
def find(id: int) -> Optional[str]: ... # str or None
def parse(x: Union[int, str]) -> int: ... # int or str
# Python 3.10+ equivalent, no imports:
def find(id: int) -> str | None: ...
def parse(x: int | str) -> int: ...
Prefer the modern X | None syntax on 3.10+. Reach for Optional/Union
from typing only when supporting older versions. Rule of thumb: Optional
is about nullability, never about whether a parameter has a default.
Both annotate a list, but List comes from typing while list is
the built-in. Since Python 3.9 the built-in containers are themselves
subscriptable (list[int], dict[str, int]), so typing.List,
typing.Dict, etc. are deprecated — use the lowercase built-ins. A bare
list means "list of anything"; the generic form pins the element type.
from typing import List # legacy
names: List[str] = []
names: list[str] = [] # modern (3.9+), preferred
scores: dict[str, int] = {}
pair: tuple[int, str] = (1, "a")
Generics let a checker verify element access and method calls. Rule of thumb:
on 3.9+ always parameterize the built-in (list[str]), and only import
from typing for things with no built-in equivalent (e.g. Callable).
Both accept any value, but they are opposites to a type checker. object
is the real base of every class — you can assign anything to it, but you
can only do object-level operations on it. Any is an escape hatch: it
is compatible with everything in both directions, so the checker stops
checking — any attribute or call is allowed.
def f(x: object) -> None:
x.upper() # type error: object has no 'upper'
def g(x: Any) -> None:
x.upper() # OK — Any disables checking
x + 1 # also OK, no complaints
Use object when you genuinely accept anything but want to keep type
safety (forcing you to narrow with isinstance first). Use Any only to
deliberately opt out of checking. Rule of thumb: Any is contagious and
hides bugs — prefer object or a precise type.
mypy is a static type checker: it reads your annotations and flags
type mismatches before you run the code — no execution, no runtime cost.
By default it checks types nominally (by inheritance). typing.Protocol
adds structural typing (a.k.a. duck typing): a class matches a Protocol
if it has the right methods/attributes, even without inheriting from it.
from typing import Protocol
class Closable(Protocol):
def close(self) -> None: ...
def shutdown(r: Closable) -> None:
r.close()
class File: # never imports/inherits Closable
def close(self) -> None: ...
shutdown(File()) # OK — File structurally matches
So mypy verifies correctness, and Protocol lets it accept anything with
the right shape rather than a specific base class. Rule of thumb: use
Protocols to type "anything that behaves like X" without forcing a
common base class.
Use Callable[[ArgTypes], ReturnType] from typing (or collections.abc). The
first element is the list of argument types, the second the return type. Use
... for "any arguments."
from typing import Callable
def apply(fn: Callable[[int, int], int], a: int, b: int) -> int:
return fn(a, b)
handler: Callable[[str], None] # takes str, returns None
any_callable: Callable[..., int] # any args, returns int
Rule of thumb: Callable[[args], ret] types higher-order functions/callbacks; use
... for the arg list when signatures vary or don't matter.
Assign a type to a name for reuse and readability; Python 3.12 adds the explicit
type statement, and typing.TypeAlias annotates one pre-3.12. Aliases make
complex types self-documenting.
from typing import TypeAlias
Vector: TypeAlias = list[float] # pre-3.12 explicit alias
Matrix = list[list[float]] # simple assignment also works
# Python 3.12+:
type UserId = int
type Json = dict[str, "Json"] | list["Json"] | str | int | bool | None
Rule of thumb: alias complex/repeated types (Vector, Json) for clarity; use the
3.12 type statement where available, else X: TypeAlias = ....
x: MyClass means an instance of the class; x: type[MyClass] means the
class object itself (or a subclass) — used when you pass classes around, e.g. for
factories.
class Animal: ...
class Dog(Animal): ...
def feed(a: Animal) -> None: ... # takes an instance
def make(cls: type[Animal]) -> Animal: # takes the class
return cls()
make(Dog) # OK — Dog is a type[Animal]
feed(Dog()) # OK — an instance
Rule of thumb: type[X] for "the class (or subclass) of X" (factories, registries);
bare X for "an instance of X."
Literal["a", "b"] restricts a value to specific constants (great for modes/
flags). Final marks a name that must not be reassigned (a constant), enforced
by the type checker.
from typing import Literal, Final
def open_mode(mode: Literal["r", "w", "a"]) -> None: ...
open_mode("r") # OK
open_mode("x") # type error — not an allowed literal
MAX_SIZE: Final = 100
MAX_SIZE = 200 # type error — can't reassign a Final
Rule of thumb: Literal to constrain to an exact set of values; Final to declare
constants the checker will protect from reassignment.
TypedDict types a dict with a fixed set of string keys and per-key value
types — ideal for JSON-like records where you want a dict (not a class) but still
want type checking on keys/values.
from typing import TypedDict
class User(TypedDict):
name: str
age: int
u: User = {"name": "Ada", "age": 36} # checked
u2: User = {"name": "Bob"} # error — missing 'age'
class Partial(TypedDict, total=False): # all keys optional
nickname: str
Rule of thumb: use TypedDict to type structured dicts (API payloads, config) while
keeping plain-dict ergonomics; use total=False for optional keys.
Use typing.Self (3.11+) for methods returning the instance's own type — cleaner
than a string forward reference and correct under subclassing. Before 3.11, use a
string literal ("MyClass") or a TypeVar bound to the class.
from typing import Self
class Builder:
def add(self, x) -> Self: # returns the same (sub)class
...
return self
class SubBuilder(Builder): ...
SubBuilder().add(1) # inferred as SubBuilder, not Builder
Rule of thumb: return Self for fluent/builder methods and alternative constructors
so subclasses get the correct return type.
A forward reference annotates a type not yet defined (e.g. a class referring
to itself) using a string. from __future__ import annotations makes all
annotations strings (lazy), so you can drop the quotes and avoid definition-order
issues.
class Node:
def __init__(self, next: "Node | None" = None): # quoted forward ref
self.next = next
# or, at the top of the file:
from __future__ import annotations
class Node:
def __init__(self, next: Node | None = None): # no quotes needed
self.next = next
Rule of thumb: quote self/forward references, or use from __future__ import annotations to make all annotations lazy strings (then read them via
typing.get_type_hints).
Annotate the element type: *args: int means each positional arg is an int
(args is a tuple[int, ...]), and **kwargs: str means each keyword value is a str
(kwargs is a dict[str, str]).
def f(*args: int, **kwargs: str) -> None:
# args: tuple[int, ...], kwargs: dict[str, str]
...
f(1, 2, 3, name="ada") # ints positionally, str values by keyword
f(1, "x") # type error — "x" isn't int
Rule of thumb: annotate *args/**kwargs with the type of each item, not the
tuple/dict — the checker infers the container.
Use -> None for functions that return nothing (procedures), and -> NoReturn
(or Never in 3.11+) for functions that never return normally — they always
raise or loop forever.
from typing import NoReturn
def log(msg: str) -> None: # returns nothing useful
print(msg)
def fail(msg: str) -> NoReturn: # always raises
raise RuntimeError(msg)
Rule of thumb: -> None for "no return value"; -> NoReturn/Never for functions
that always raise or never terminate (helps the checker's flow analysis).
Gradual typing lets you add hints piecemeal — unannotated code is treated as
Any and not checked, so you can type the most critical modules first. Tools like
mypy support per-module strictness to ratchet up coverage.
# type the public API first, leave internals untyped for now
def public_api(user_id: int) -> str:
return _helper(user_id) # _helper untyped -> treated as Any
def _helper(x): # no hints yet — not checked
return str(x)
# mypy.ini — enforce strictness only where ready
[mypy-myapp.api.*]
disallow_untyped_defs = True
Rule of thumb: adopt types gradually from the boundaries inward; use per-module mypy config to enforce strictness only where the code is fully annotated.
More Type Hints & Typing interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.