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.” The
heuristic also has real limits: a bare alias whose right-hand side is not a recognisable type
expression — say Handler = get_handler() — is silently taken as a runtime value, and the checker
will later reject x: Handler as “not a valid type” with no hint that you intended an alias. On
Python 3.8, subscripting builtins like list[float] at runtime is itself a TypeError, so pre-3.9
code must write List[float] from typing or wrap the whole alias in quotes.
Recognition is all-or-nothing at the point of assignment. Once a checker decides a bare name is an
alias, every later annotation that uses it is resolved to the full right-hand type and errors are
reported against that expansion — the tracking is precise, not fuzzy. The real failure mode is
misclassification: a right-hand side the checker does not recognise as a type (a function call, a
conditional expression, a comprehension) is silently taken as a runtime value, and the mistake only
surfaces far away at the first x: ThatName use as “Variable … is not valid as a type”
([valid-type]). The explicit marker in Step 2 exists precisely to move that error back to the
declaration.
Aliases used only in annotations can also live behind an if TYPE_CHECKING: guard so their
right-hand side is never imported at runtime — handy when the referenced types are expensive to
import or would create a cycle. Under that guard even an eager bare assignment is safe from a
NameError, because the block never executes at runtime while the checker still reads it. This is
the conventional way to keep typing-only imports out of a module’s runtime path, and it composes with
all three alias forms, though the type statement’s own laziness already removes most of the need for
it.
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. Import it from
typing_extensions on Python 3.8–3.9.
# 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. Two further constraints are worth knowing. TypeAlias
carries no type parameters of its own, so a generic alias still needs an explicit
TypeVar on the right —
Pair: TypeAlias = tuple[T, T] with T = TypeVar("T") — which the type statement makes obsolete.
And the marker only means “this is an alias declaration”: using it as an ordinary variable annotation
such as count: TypeAlias = 0 is meaningless and mypy rejects it as [misc], “Invalid type alias”.
A union is the most common alias of all, and it exposes a version trap. type and TypeAlias both
accept the X | Y syntax, but the bare Value = int | str form evaluates int | str eagerly,
which only works as a runtime expression on Python 3.10+ (where | between types returns a
types.UnionType). On 3.8–3.9 write Union[int, str] from typing, quote the whole right-hand
side, or add from __future__ import annotations — though, as covered in Step 3, that future import
does not defer an alias assignment’s own right-hand side, only annotations.
It is also worth separating an alias from a NewType, because they solve opposite problems. An
alias is fully transparent: Vector and list[float] are the same type to the checker, freely
assignable in both directions, so it only improves readability. NewType creates a distinct type
that is assignable from its base but not silently to it, giving you a lightweight nominal wrapper:
# Python 3.10+, mypy 1.x
from typing import NewType, TypeAlias
UserId = NewType("UserId", int) # a distinct type
Age: TypeAlias = int # a transparent alias
def load(uid: UserId) -> None: ...
load(42) # error: int is not UserId [arg-type]
load(UserId(42)) # OK
Reach for TypeAlias/type when you want a readable name for a complex type, and for NewType when
you want the checker to stop two structurally identical types from being confused.
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.
The soft keyword does not shadow the type() builtin — type(x) still returns the class of x; the
statement form is recognised only in the specific type NAME = ... position. Because evaluation is
deferred, the right-hand side may even reference the alias itself, so directly recursive definitions
that would loop forever as a bare assignment become legal:
# Python 3.12+
type JSON = dict[str, "JSON"] | list["JSON"] | str | int | float | bool | None
Lazy evaluation likewise enables mutual recursion between two aliases, which no eager form can express because each would otherwise need the other to exist first:
# Python 3.12+
type Tree = list[Branch]
type Branch = tuple[str, Tree]
Neither line reads the other’s .__value__ at definition time, so the forward reference from Tree
to Branch resolves cleanly whenever a checker or caller finally expands the pair.
Note that from __future__ import annotations (PEP 563) does not rescue the eager forms: it defers
only annotations to strings, not the right-hand side of an assignment, so Edge = list[Node] still
raises at import under from __future__ import annotations. The type statement is the real fix.
Generic type aliases accept the full PEP 695 parameter syntax, including bounds and constraints, so
you can restrict the type parameter directly on the alias:
# Python 3.12+
type Number[T: (int, float)] = list[T] # constrained: T must be int or float
type Sorted[T: int] = list[T] # upper bound: T must be a subtype of int
At runtime the alias exposes all of this through introspection, and its deferred value through
__value__:
# Python 3.12+
type Pair[T] = tuple[T, T]
Pair.__name__ # 'Pair'
Pair.__type_params__ # (T,)
Pair.__value__ # tuple[T, T] — computed on first access
Both checkers treat every alias form transparently: a mismatch involving Pair[int] is reported in
terms of tuple[int, int], its expansion, not the alias name — so the error messages stay concrete.
Adoption is gated by tooling, not only the interpreter: mypy needs 1.11+ with python_version = 3.12 in its config, and pyright keys off pythonVersion in pyrightconfig.json or pyproject.toml.
If either is set below 3.12 the statement is reported as a syntax error before any analysis runs — a
common false alarm when a repo’s tool config lags its actual runtime. Finally, PEP 695
soft-deprecates typing.TypeAlias: it stays valid and remains the right choice for 3.10–3.11 code,
but checkers may eventually nudge 3.12+ codebases toward the type statement. There is no runtime
deprecation warning today.
Edge cases
Aliases are mostly frictionless, but a few interactions catch people out — around runtime
isinstance, tooling that must run on older interpreters, and the fact that a type alias is now a
real object rather than a bare name:
- 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, or the alias’s.__value__if it resolves to a class. - 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.__name__,MyAlias.__type_params__,MyAlias.__value__), which tooling and documentation generators can read far more reliably than a bare assignment, which is indistinguishable from any other module global at runtime. - Aliases as generic bases: A generic
typealias can parameterise other generics —type StrDict[V] = dict[str, V]used asStrDict[int]— and the checker propagatesVcorrectly, something a bareTypeVaralias only approximates.
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. - Reaching for
isinstanceon an alias.isinstance(x, Vector)fails for every alias form when the target is a subscripted or union type; check against the concrete runtime class instead. - Writing a recursive bare alias.
Tree = dict[str, Tree]as a bare assignment recurses at import; only the lazytypestatement can express a directly recursive alias safely.
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.