The type Statement vs TypeAlias: Three Ways to Declare an Alias
Python gives you three ways to declare a type alias:
a bare assignment Vector = list[float], an explicit Vector: TypeAlias = list[float] (PEP 613),
and the PEP 695 type Vector = list[float] statement (Python 3.12+). They look interchangeable, but
mypy and pyright treat them differently — especially around forward references and lazy evaluation.
This guide explains how each is seen by the analyzers and when the differences actually bite.
Bare assignment works everywhere but is ambiguous to checkers. TypeAlias (PEP 613, Python 3.10+)
makes the intent explicit and catches mistakes. The PEP 695 type X = ... statement (Python 3.12+)
is lazily evaluated — so forward references just work — and supports generic aliases like
type Pair[T] = tuple[T, T]. Prefer the type statement on 3.12+.
An alias is just a name that stands in for a type expression. The three forms differ in when the right-hand side is evaluated and in how confidently the analyzer can tell you meant an alias rather than an ordinary runtime variable. PEP 613 (Python 3.10) added the explicit marker; PEP 695 (Python 3.12) added a dedicated statement with lazy evaluation baked in.
Step 1: The bare assignment
The oldest form is a plain module-level assignment. Every analyzer recognizes it, but only by heuristic — there is no syntactic signal that you meant an alias rather than a runtime value.
# Python 3.8+, mypy 1.x
Vector = list[float]
def normalize(v: Vector) -> Vector:
...
Because the right-hand side is an ordinary expression, it is evaluated eagerly at import. A forward reference to a name not yet defined therefore needs string quoting:
# Python 3.8+, mypy 1.x
NodeList = list["Node"] # "Node" quoted: it is defined later
class Node:
children: NodeList
Drop the quotes and you get a runtime NameError at import, even though mypy is happy with the
annotation. mypy reports the unquoted version as [name-defined]: “Name ‘Node’ is not defined.”
Step 2: The explicit TypeAlias (PEP 613)
Python 3.10 added typing.TypeAlias so you can declare that a name is an alias. The checker now
validates the right-hand side as a type and flags misuse instead of guessing.
# Python 3.10+, mypy 1.x
from typing import TypeAlias
Vector: TypeAlias = list[float] # explicitly an alias
ConnectionId: TypeAlias = "int | str" # forward-ref string still allowed
def fetch(ids: list[ConnectionId]) -> None:
...
The payoff is error detection. If the right-hand side is not a valid type, mypy says so directly, which a bare assignment would silently accept as a runtime value:
# Python 3.10+, mypy 1.x
from typing import TypeAlias
Broken: TypeAlias = compute_default() # not a type expression
# mypy error: [misc] — Invalid type alias: expression is not a valid type
# pyright: reportGeneralTypeIssues
TypeAlias is still eagerly evaluated, so unquoted forward references in the value remain a
runtime hazard exactly as with bare assignment.
Step 3: The PEP 695 type statement
Python 3.12 introduced a dedicated soft keyword, type, that creates a TypeAliasType object. Two
things change: evaluation is lazy, and the alias can take its own type parameters.
# Python 3.12+, mypy 1.11+
type Vector = list[float]
type Pair[T] = tuple[T, T] # a generic alias, no TypeVar import needed
def midpoint(p: Pair[float]) -> float:
...
Lazy evaluation means the right-hand side is not computed until the alias is actually accessed, so forward references work without quotes — the name only needs to exist by the time the alias is resolved, not at the point of definition:
# Python 3.12+, mypy 1.11+
type NodeList = list[Node] # Node defined below — no quotes, no NameError
class Node:
children: NodeList
The generic form type Pair[T] = ... binds T to the alias itself, so Pair[int] and Pair[str]
are distinct instantiations the checker tracks. This replaces the older
TypeVar-plus-bare-assignment dance under
PEP 695 type parameter syntax.
Edge cases
- Mixing
typealiases withisinstance: A PEP 695 alias is not a class, soisinstance(x, Vector)raisesTypeError: isinstance() arg 2 must be a typeat runtime. Use the underlying type (list) for runtime checks. - Old mypy versions: PEP 695 support landed in mypy 1.11+ behind full 3.12 parsing. On older
mypy the
typestatement is a syntax error — pinmypy>=1.11in CI before adopting it across a monorepo. - Exporting aliases: Because a
typealias is aTypeAliasTypeobject, it is importable and introspectable (MyAlias.__type_params__), which tooling and documentation generators can read more reliably than a bare assignment.
Common mistakes
- Unquoted forward references in eager forms.
Edge = list[Node]beforeNodeexists raisesNameErrorat import; mypy may still pass the annotation, so the failure surfaces only at runtime. Quote the name, reorder definitions, or switch to thetypestatement. - Using
TypeAliasas an annotation on a real variable.count: TypeAlias = 0is meaningless —TypeAliasis only for declaring aliases. mypy flags it as[misc]“Invalid type alias.” - Assuming the
typestatement runs on 3.11. It is 3.12+ syntax. Importing such a module under 3.11 raisesSyntaxErrorbefore any type checker sees it; gate the version in your tooling config.
FAQ
Should new 3.12 code always use the type statement?
For aliases, yes — it is explicit, lazily evaluated, and supports generics without a TypeVar
import. Keep TypeAlias only where you must support Python 3.10–3.11.
Does lazy evaluation change how mypy resolves the alias?
No — mypy resolves all three forms to the same underlying type. Lazy evaluation only affects
runtime behavior (no eager NameError); the static type is identical.