Back to Blog
Python

Python Type Parameter Syntax

Learn about Python's modern type parameter syntax introduced in PEP 695, how it compares with the traditional TypeVar approach, and when to use each.

PEP 695GenericsType HintsTypeVarStatic Typing
Illustration of Python generic type parameter syntax showing a boxed T type parameter between square brackets in a code editor.

Python's type parameter syntax changed significantly with the release of Python 3.12. The traditional approach using typing.TypeVar is still valid, but a new, more concise syntax now exists for declaring generic functions, classes, and type aliases. This article explains the new syntax, shows how it maps to the old approach, and covers practical considerations for adopting it in your codebase.

The New Syntax: PEP 695

PEP 695 introduces a dedicated syntax for type parameters. Instead of creating a TypeVar and then using it in a function signature, you can now declare type parameters directly in the function or class definition. The syntax uses square brackets after the function name or class name.

For a generic function, the type parameter is declared between the function name and the parameter list:

def first_element[T](items: list[T]) -> T: return items[0]

For a generic class, the type parameter is declared after the class name:

class Stack[T]: def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop()

This syntax is more direct and easier to read. The type parameter T is in scope for the entire function or class body, including method signatures and attribute annotations.

Comparing the Old TypeVar Approach

Before Python 3.12, you had to create a TypeVar explicitly and then reference it. The equivalent generic function using typing.TypeVar looks like this:

from typing import TypeVar T = TypeVar("T") def first_element(items: list[T]) -> T: return items[0]

The class version is similar:

from typing import TypeVar, Generic T = TypeVar("T") class Stack(Generic[T]): def __init__(self) -> None: self._items: list[T] = [] def push(self, item: T) -> None: self._items.append(item) def pop(self) -> T: return self._items.pop()

The old approach requires more boilerplate. You must import TypeVar, create an instance, and for classes, inherit from Generic[T]. The new syntax eliminates this extra code and keeps the type parameter definition local to where it is used.

Type Parameter Bounds and Constraints

The new syntax also supports bounds and constraints, which restrict what types can be used as arguments for the type parameter.

A bound restricts the type parameter to a specific type or its subclasses. In the new syntax, you use the : operator with a type expression:

def max_value[T: int | float](items: list[T]) -> T: return max(items)

This is equivalent to the old TypeVar with a bound:

from typing import TypeVar T = TypeVar("T", bound=int | float) def max_value(items: list[T]) -> T: return max(items)

Constraints limit the type parameter to an explicit set of types. In the new syntax, the constraint set is written as a tuple after the : operator. The old syntax passes the allowed types as additional arguments to TypeVar:

# New syntax def parse_int_or_float[T: (int, float)](value: str) -> T: ... # Old syntax from typing import TypeVar T = TypeVar("T", int, float) def parse_int_or_float(value: str) -> T: ...

This distinction matters. T: int | float is a bound, so a type argument can be int, float, or a subtype of either. A constrained T: (int, float) is restricted to the listed constraint types and does not treat subtypes such as bool as an additional candidate. This matches the behavior of the old constrained TypeVar.

Type Aliases with Type Parameters

The new syntax also simplifies generic type aliases. In the old approach, you would write:

from typing import TypeAlias, TypeVar T = TypeVar("T") Result: TypeAlias = tuple[T, str]

With PEP 695, you can declare a generic type alias directly:

type Result[T] = tuple[T, str]

This is cleaner and makes the type parameter explicit in the alias declaration. The alias can then be used like any other generic type:

def process(value: int) -> Result[int]: return (value, "ok")

Scope and Reusability Differences

One visible difference is that old TypeVar objects live at module scope, so the same name can be reused in several declarations. This is convenient, but reusing a TypeVar in separate functions does not make those functions operate on the same concrete type. Each function is independent and binds the type variable per call. The new syntax keeps type parameters scoped to a single function, class, or alias, which is usually clearer.

If you need to relate multiple methods, use a type parameter on the enclosing class. The old class syntax did this with Generic[T]; the new syntax does it with class Stack[T]:. Module-level TypeVar reuse is still available when you need to write code in the older style, but it does not create a cross-function constraint by itself.

Runtime Behavior and Compatibility

PEP 695 type parameters are evaluated at runtime. The __type_params__ attribute on the function or class object holds the type parameter objects. This is similar to how __parameters__ works for generic classes in the old system, but the new syntax provides a more direct introspection path.

For example:

def first_element[T](items: list[T]) -> T: return items[0] print(first_element.__type_params__)

This will print a tuple containing the TypeVar object for T. This is useful for libraries that need to introspect generic functions or classes.

A key compatibility consideration is that the new syntax requires Python 3.12 or later. If you are writing code that must run on earlier Python versions, you cannot use PEP 695 syntax. In that case, you must stick with the typing.TypeVar approach. Type checkers like mypy and pyright support the new syntax, but only when the target Python version is set to 3.12 or higher.

When to Use Which Syntax

For new code that targets Python 3.12 or later, the PEP 695 syntax is the preferred choice. It reduces boilerplate, improves readability, and keeps type parameters local to their use. The old TypeVar syntax remains necessary when you must maintain compatibility with Python versions before 3.12, or when your codebase depends on module-level type variable reuse.

Neither syntax changes how function bodies execute. Type parameter objects are created at definition time for both approaches, and both offer similar introspection capabilities. The choice is primarily about code clarity and version support.

When migrating existing code, you can incrementally adopt the new syntax. A function that uses a module-level TypeVar only once can be safely converted. If a TypeVar is used in several places, treat each use as a separate local type parameter unless you have a reason to preserve the old module-level object. In particular, reusing a TypeVar across separate functions does not by itself link their concrete types.

The new type statement for aliases is a clear improvement over the old TypeAlias assignment, but it also requires Python 3.12. For libraries that support multiple Python versions, the old syntax will remain in use for the foreseeable future. Understanding both forms is essential for reading and writing modern Python code.

Python Type Parameter Syntax: PEP 695 vs. TypeVar | RYUSLOG DEV