Posted in

Python Generic Types Explained: TypeVar, Generics, Constraints & PEP 695

Python Generic Types allow you to write reusable, type-safe code without sacrificing flexibility. In this lesson, you’ll learn how TypeVar works, how to create generic functions and classes, when to use constraints and bounds, and how Python 3.12’s PEP 695 modernizes generic syntax.
Python Generic Types explained with TypeVar, generic functions, generic classes, constraints, bounds, and PEP 695 syntax.
Learn how Python Generic Types, TypeVar, Generic Classes, Constraints, and PEP 695 help create reusable and type-safe code.

Introduction: Python Generic Types

In the previous lesson, you learned how to make type hints more flexible using tools such as Union, Optional, Any, Literal, and other special type hints. These tools allow variables, function parameters, and return values to work with multiple possible types instead of being restricted to a single type.

You also learned how Python’s built-in collection type hints such as list[str], dict[str, int], and set[float] help describe the types of values stored inside containers. This makes type hints more precise and improves code readability, editor support, and static type checking.

However, as programs become larger, another challenge begins to appear.

Suppose you want to write a function that works with integers, strings, custom classes, or virtually any other type. You could use Any, but that removes much of the type safety that type hints are designed to provide. Alternatively, you could create separate versions of the same function for every possible type, but that quickly becomes repetitive and difficult to maintain.

What we really need is a way to write code once and have it work with many different types while still preserving accurate type information.

This is exactly the problem that Python Generic Types solve.

Generic types allow us to create reusable, type-safe functions and classes without sacrificing flexibility. They are one of the most powerful features of Python’s type hinting system and are widely used throughout the standard library, third-party libraries, and modern Python applications.

What You’ll Learn

In this lesson, you’ll learn:

  • What problem generic types solve
  • What generic types are and how they work
  • How to create and use TypeVar
  • How generic functions preserve type information
  • How to build reusable generic classes
  • The difference between generic types and Any
  • How constraints and bounds work
  • What AnyStr is and why it exists
  • The modern generic syntax introduced by PEP 695 (Python 3.12+)

By the end of this lesson, you’ll understand not only how to use generic types, but also why they are considered one of the foundations of modern static typing in Python.

Before we understand what Python generic types are, we first need to understand the problem they solve and why Python needs generic types in the first place.


Part 1 — The Problem Generics Solve

Before learning about TypeVar, generic functions, or generic classes, it’s important to understand why generic types were added to Python’s type hinting system in the first place.

Many Python features exist because developers repeatedly encounter the same practical problem. Generic types are no different. They were introduced to solve a challenge that appears whenever we try to write code that is both flexible and type-safe.

Let’s start by looking at the common approaches developers might try before generics enter the picture.


1.1 The Limitation of Any

One possible solution is to use Any.

Suppose we want a function that simply returns the value it receives:

from typing import Any

def identity(value: Any) -> Any:
    return value

This function can accept any type:

identity(10)
identity("Hello")
identity([1, 2, 3])

At first glance, this seems perfect.

The function is flexible and works with everything.

However, there is a hidden problem.

Because both the parameter and return type are marked as Any, the type checker loses track of the relationship between them.

Consider:

result = identity("Python")

As humans, we know that result will contain a string.

However, from the type checker’s perspective:

result: Any

The specific type information has been lost.

This means:

  • Autocompletion becomes less useful.
  • Type checking becomes less accurate.
  • Mistakes become harder to detect.
  • The benefits of type hints begin to disappear.

In other words, Any gives us flexibility, but often at the cost of type safety.

Think of Any as telling the type checker:

“Don’t worry about this value. Trust me.”

Sometimes that’s necessary, but if used everywhere, the type checker can no longer provide meaningful help.


1.2 The Limitation of Writing Separate Functions

If Any removes too much type information, another idea might be to create separate functions for different types.

For example:

def identity_int(value: int) -> int:
    return value

def identity_str(value: str) -> str:
    return value

This works, but a new problem appears immediately.

What if we also need to support:

  • float
  • bool
  • list
  • dict
  • Custom classes
  • Future types we haven’t created yet

We would end up writing many nearly identical functions:

def identity_float(value: float) -> float:
    return value

def identity_user(user: User) -> User:
    return user

def identity_product(product: Product) -> Product:
    return product

The logic never changes.

Only the types change.

This creates unnecessary duplication and makes code harder to maintain.

Whenever we find ourselves copying the same logic repeatedly for different types, it’s often a sign that we need a more general solution.


1.3 Why Python Needs Reusable Type-Safe Code

In real-world software, developers frequently write code that should work with many different types.

Consider a function that returns the first item from a collection:

def first_item(items):
    return items[0]

This function works for:

numbers = [1, 2, 3]
names = ["Alice", "Bob"]
users = [user_one, user_two]

The underlying logic is always the same.

The function simply returns the first element.

The challenge is describing this behavior using type hints.

Ideally, we want the type checker to understand:

  • If the input contains integers, the return value should be an integer.
  • If the input contains strings, the return value should be a string.
  • If the input contains User objects, the return value should be a User.

In other words, we want flexibility without losing type information.

This is the key requirement that neither Any nor duplicated functions can fully solve.

Modern software development relies heavily on reusable components:

  • Data structures
  • Utility functions
  • Frameworks
  • Libraries
  • APIs

These components must work with many types while still providing accurate type information.

Python needed a way to express this idea.


1.4 What Problem Do Generics Actually Solve?

Generic types solve a very specific problem:

How can we write code that works with many different types while preserving the relationship between those types?

Consider this function:

def identity(value):
    return value

The logic is simple:

  • Input type goes in.
  • The same type comes out.

If we pass an integer:

identity(10)

the result should be an integer.

If we pass a string:

identity("Python")

the result should be a string.

The function’s behavior stays the same regardless of the type being used.

What changes is the type itself.

Generic types allow us to describe this relationship directly.

Instead of saying:

“This function accepts any type.”

we can say:

“This function accepts some type, and whatever that type is, the same type will be returned.”

That may sound like a small difference, but it is the foundation of generic programming.

Rather than writing separate versions of the same code for every possible type, we can write the logic once and allow the type to be supplied later.

This gives us both:

  • Reusability
  • Type safety

And that is exactly what generic types were designed to achieve.

Now that we understand the problem generics solve, we can begin exploring what generic types actually are and how Python represents them using TypeVar.


Part 2 — Understanding Generic Types

Now that we understand the problem generics solve, we can finally answer an important question:

What exactly is a generic type?

The term “generic” can sound intimidating at first, but the underlying idea is actually quite simple. Generic types allow us to create reusable type definitions that can work with many different types while still preserving accurate type information.

Let’s build this idea step by step.


2.1 What Are Generic Types?

A generic type is a type that contains one or more type placeholders.

Instead of being tied to a specific type such as int or str, a generic type allows the actual type to be supplied later.

For example:

list[int]

and

list[str]

are both based on the same generic type:

list

The structure remains the same, but the type stored inside the list changes.

Similarly:

dict[str, int]

and

dict[str, float]

both use the same generic dictionary type.

The only difference is the types used for keys and values.

In simple terms:

A generic type is a reusable type template that can be customized with specific types when needed.

This allows us to write flexible code without losing information about the actual types being used.

A Small Observation

You may not realize it, but you’ve already been using generic types throughout this chapter.

For example:

list[str]
dict[str, int]
set[float]
tuple[str, int]

All of these are generic types.

The concept itself is not new. What is new is learning how to create and use our own generic types.


2.2 The Box Analogy

One of the easiest ways to understand generic types is through a box analogy.

Imagine you own a special box.

The box’s design never changes.

However, the contents of the box can vary.

Sometimes the box stores integers:

Box[int]

Sometimes it stores strings:

Box[str]

The box itself remains the same.

Only the type of object inside the box changes. The box acts as a reusable template.

Instead of creating:

IntegerBox
StringBox
UserBox

we create a single generic box that can work with many different types.

This is exactly the idea behind generic programming.

We write the structure once and allow the specific type to be supplied later.

Real-World Parallel

Python’s built-in collections work the same way:

list[int]
list[str]
list[float]

There is only one list type.

Python doesn’t create a completely different list implementation for each data type.

The list structure remains the same while the contained type changes.


2.3 Generic Types vs Any

At this point, you might wonder:

Why not just use Any instead?

After all, both approaches seem flexible.

Consider this function:

from typing import Any

def process(data: Any) -> Any:
    return data

This function accepts everything:

process(10)
process("Python")
process([1, 2, 3])

However, there is an important difference.

With Any, the type checker stops tracking the actual type.

For example:

result = process("Python")

The type checker sees:

result: Any

The information that the value is a string has been lost.

Generic types work differently.

Their goal is not merely to accept many types.

Their goal is to preserve the relationship between those types.

Think of it this way:

Any says:

“This could be anything. I don’t know what it is.”

Generic types say:

“I don’t know the type yet, but once it is chosen, I’ll remember it.”

That difference is the entire reason generic types exist.

Insight

Many beginners assume that generics are simply a more complicated version of Any.

In reality, they solve opposite problems.

  • Any intentionally removes type information.
  • Generics preserve type information.

That’s why generic types are considered type-safe while Any is often called an escape hatch.


2.4 Generic Types Are Mostly for Type Checkers

When beginners first encounter generic types, they often assume something special happens at runtime.

However, that’s not how Python typically works.

Generic types primarily exist for:

  • Type checkers
  • IDEs
  • Static analysis tools
  • Developer understanding

Their main purpose is to provide additional type information.

For example:

numbers: list[int] = [1, 2, 3]

A type checker understands:

  • Every element should be an integer.
  • Operations should be validated accordingly.
  • Incorrect types can be reported as errors.

But at runtime:

type(numbers)

still produces:

<class 'list'>

Python does not create a separate runtime list implementation for every possible type.

Likewise:

list[int]
list[str]
list[float]

all use the same underlying list class.

The generic information mainly exists to help tools and developers reason about code before it runs.


2.5 Visual Summary: Understanding Generic Types

Before moving on to TypeVar, let’s quickly review the key ideas we’ve covered so far. The following infographic summarizes what generic types are, how they differ from Any, why the box analogy is useful, and how generic types help type checkers preserve type information.

Understanding Python Generic Types infographic showing generic types, box analogy, generics vs Any, and how type checkers use generic type information.

As you can see, generic types provide a balance between flexibility and type safety. Rather than losing type information like Any, generics preserve the relationship between types while allowing code to remain reusable. This idea forms the foundation for everything we’ll learn next, because TypeVar is the mechanism Python uses to create these generic type relationships.


Part 3 — Understanding TypeVar

In the previous section, we learned that generic types allow us to write reusable, type-safe code. However, we still haven’t answered an important question:

How does Python represent the “unknown type” inside a generic type?

For example, if we want a function to work with integers, strings, lists, custom classes, or any other type while preserving type information, how do we describe that relationship?

The answer is TypeVar.

TypeVar is the fundamental building block that makes generic programming possible in Python. Almost every generic function and generic class ultimately relies on one or more type variables.

Let’s see how it works.


3.1 What Is TypeVar?

A TypeVar (Type Variable) is a special object that represents a placeholder for a type.

It allows us to write type hints without knowing the exact type in advance.

For example, imagine we want to describe:

“This function accepts some type and returns the same type.”

At the moment of writing the function, we don’t know whether that type will be:

  • int
  • str
  • float
  • list
  • User
  • or something else entirely

Instead of choosing a specific type, we can use a placeholder.

That placeholder is a TypeVar.

Important

A TypeVar is not a real type such as int or str.

It simply acts as a symbol that stands in place of an actual type until one is provided.

You can think of it as a blank space in a form:

TypeVar → "A type will go here later."

The actual type is chosen when the function or class is used.


3.2 Creating Your First TypeVar

To create a type variable, import TypeVar from the typing module:

from typing import TypeVar

Then create a type variable:

from typing import TypeVar

T = TypeVar("T")

This is the most common generic type variable you’ll see in Python code.

The string "T" is simply the name of the type variable.

Now we can use T inside type hints:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

Here:

  • The parameter type is T
  • The return type is also T

We’re telling the type checker:

Whatever type goes in should be the same type that comes out.

For example:

identity(10)

The type checker understands:

int -> int

And:

identity("Python")

becomes:

str -> str

The function logic never changes.

Only the actual type represented by T changes.

Insight

Notice that we never explicitly tell Python:

T = int

or

T = str

The type checker automatically figures that out based on how the function is called.

This process is called type inference, which we’ll explore in more detail later.


3.3 Common TypeVar Naming Conventions

Technically, you can give a TypeVar almost any valid variable name:

UserType = TypeVar("UserType")

or

StoredValueType = TypeVar("StoredValueType")

However, Python developers usually follow a few common conventions.

T

Represents a general type.

T = TypeVar("T")

Meaning:

T = Type

This is the most common type variable you’ll encounter.

K

Represents a key type.

K = TypeVar("K")

Meaning:

K = Key

Often used with dictionaries.

V

Represents a value type.

V = TypeVar("V")

Meaning:

V = Value

Usually paired with K.

Example:

K = TypeVar("K")
V = TypeVar("V")

S

Represents a secondary type.

S = TypeVar("S")

Often used when a function or class needs multiple type variables.

Example

K = TypeVar("K")
V = TypeVar("V")

might represent:

dict[K, V]

where:

dict[str, int]

means:

K = str
V = int

Good Practice

For simple examples, use:

T
K
V
S

For complex codebases, longer descriptive names may improve readability.


3.4 One Important Rule: Consistent Types

This is the most important concept to understand about TypeVar.

A TypeVar represents one consistent type during a single function call.

Consider:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

If we call:

identity(10)

then:

T = int

for that call.

If we call:

identity("Python")

then:

T = str

for that call.

The type can change between calls.

However, within a single call, the type must remain consistent.

Think of T as a temporary label.

When a function call begins, the type checker chooses a real type for T.

After that choice is made, every occurrence of T refers to the same type for that specific call.

Why This Matters

Suppose we have:

from typing import TypeVar

T = TypeVar("T")

def duplicate(value: T) -> tuple[T, T]:
    return value, value

If we pass:

duplicate("Hello")

the type checker understands:

tuple[str, str]

If we pass:

duplicate(100)

the result becomes:

tuple[int, int]

The same type is preserved throughout the entire relationship.

This consistency is what makes generic programming type-safe.


3.5 Common Questions About TypeVar

As you begin experimenting with TypeVar, you may encounter a few behaviors that seem unusual compared to other type hints. Let’s clear up some common questions and confusion points.

Why Does TypeVar Require an Extra Step?

Most type hints are used directly after importing them.

For example:

from typing import Any, Literal

user_data: Any

status: Literal["active", "inactive"]

After importing Any or Literal, they can immediately be used inside type hints.

TypeVar works differently.

First, you import it:

from typing import TypeVar

Then you create a type variable:

T = TypeVar("T")

Only after creating T can it be used in type annotations:

def identity(value: T) -> T:
    return value

In other words, TypeVar is not itself a type hint. Instead, it is a tool used to create placeholder types that can later be used in annotations.

A common pattern looks like this:

from typing import TypeVar

T = TypeVar("T")

# Use T in type hints

Why Do We Write Both T and "T"?

When creating a type variable, you may notice that the name appears twice:

T = TypeVar("T")

The first T is the Python variable name.

The second "T" is the internal name stored inside the TypeVar object.

These names should match.

For example:

T = TypeVar("T")
K = TypeVar("K")
V = TypeVar("V")

are all correct.

However:

T = TypeVar("K")

will usually produce a warning from type checkers.

You may see a message similar to:

The argument to 'TypeVar()' must be a string equal to the variable name to which it is assigned.

Does Mismatching the Names Break Generic Type Checking?

No.

Consider:

from typing import TypeVar

T = TypeVar("K")

def identity(value: T) -> T:
    return value

print(identity([1, 2, 3]))
print(identity("Hello"))

This code runs perfectly.

The generic behavior still works.

The warning exists because the typing ecosystem expects the variable name and the internal TypeVar name to match for readability and consistency.

The warning is about convention, not functionality.

How Does Python Know What Type T Represents?

Consider:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

When we call:

identity("Hello")

the type checker infers:

T = str

For that specific function call.

The function is effectively interpreted as:

def identity(value: str) -> str:
    return value

Likewise:

identity(100)

is interpreted as:

def identity(value: int) -> int:
    return value

This process is known as type inference.

Does T Permanently Become a Specific Type?

No.

A TypeVar does not permanently store a type.

Instead, the type checker determines a value for T separately for each function call.

For example:

identity("Hello")

may infer:

T = str

while:

identity(100)

may infer:

T = int

The type can change from one call to another.

The important rule is that all occurrences of T must refer to the same type within a single call.

Does Using TypeVar Force the Return Type to Be T?

No.

Many beginners assume that if a parameter uses a TypeVar, then the return type must also use the same TypeVar.

This is not true.

For example:

from typing import TypeVar

T = TypeVar("T")

def type_name(value: T) -> str:
    return type(value).__name__

This is perfectly valid.

The function accepts any type but always returns a string.

A TypeVar only creates relationships where it is explicitly used.

Then Why Do Most Generic Examples Use T as Both the Parameter and Return Type?

Consider:

def identity(value: T) -> T:
    return value

This creates a relationship between the parameter and return value.

It tells the type checker:

Whatever type goes in should be the same type that comes out.

For example:

identity("Hello")

becomes:

str -> str

This is one of the most common uses of generic programming.

What Happens If I Write T as the Parameter Type but int as the Return Type?

Consider:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> int:
    return value

At first glance, some readers assume the error occurs because:

identity("Hello")

causes:

T = str

and therefore the return type should also be str.

However, that is not the real issue.

The function signature:

T -> int

is completely valid.

For example:

def always_zero(value: T) -> int:
    return 0

is perfectly acceptable.

The problem is the implementation:

return value

The type checker cannot guarantee that value is always an integer.

Since T could represent many different types, returning value may violate the promise that the function returns an int.

Does TypeVar Perform Runtime Type Checking?

No.

TypeVar exists entirely within Python’s type-hinting system.

For example:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> int:
    return value

print(identity("Hello"))

Python will still execute this code and print:

Hello

because Python does not enforce type hints at runtime.

Type hints are primarily intended for:

  • Type checkers
  • IDEs
  • Static analysis tools
  • Documentation

Can TypeVar Be Used for Ordinary Variables?

A common beginner question is whether a TypeVar can be used directly as the type of a normal variable.

For example:

from typing import TypeVar

T = TypeVar("T")

user_name: T = "PyCoder"

Most type checkers will report a warning similar to:

Type variable "T" is unbound

This happens because a TypeVar is not a real type. It is a placeholder type that must be associated with a generic function, generic class, or generic type alias.

In a generic function:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

the type checker can determine what T represents when the function is called.

However, in:

user_name: T = "PyCoder"

there is no generic context that tells the type checker what T should be, so the type variable remains unbound.


Key Takeaway

A useful way to think about TypeVar is that it does not represent a fixed type. Instead, it represents a relationship between types. The type checker infers a concrete type whenever the function or class is used and then ensures that all occurrences of the same TypeVar remain consistent within that context.


Part 4 — Generic Functions

Now that we understand what a TypeVar is, we can finally start using it for its intended purpose: creating generic functions.

A generic function is simply a function that uses one or more type variables in its type hints. This allows the function to work with multiple types while preserving type information.

The function logic remains the same, but the actual types can vary depending on how the function is called.

This is where generic programming starts becoming truly useful.


4.1 Your First Generic Function

Let’s begin with the classic example:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

This function returns whatever value it receives.

At runtime, the behavior is extremely simple:

print(identity(100))
print(identity("Python"))
print(identity([1, 2, 3]))

Output:

100
Python
[1, 2, 3]

The interesting part is not the runtime behavior.

The interesting part is the type information.

The function annotation:

def identity(value: T) -> T:

tells the type checker:

Whatever type is passed into this function should be the same type returned by the function.

As a result:

identity(100)

is interpreted as:

int → int

while:

identity("Python")

is interpreted as:

str → str

and:

identity([1, 2, 3])

becomes:

list[int] → list[int]

The function implementation never changes.

Only the type represented by T changes.

Why Not Use Any?

Compare the generic version:

def identity(value: T) -> T:

with:

from typing import Any

def identity(value: Any) -> Any:
    return value

Both functions can accept many types.

However, the generic version preserves the relationship between the parameter and return value, while Any loses that information.

This is why generic functions are considered type-safe.


4.2 How Type Inference Works

One of the most impressive aspects of generic functions is that we rarely need to tell Python what type T represents.

The type checker usually figures it out automatically.

This process is called type inference.

Consider:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

Now suppose we call:

identity("Hello")

The type checker sees:

value parameter: T
actual argument: str

It therefore infers:

T = str

and temporarily treats the function as:

def identity(value: str) -> str:
    return value

Important Insight

Many beginners imagine that Python creates a new version of the function for every type.

That is not what happens.

There is only one function object at runtime. The type checker is simply using the type hints to understand how types flow through the function.


4.3 Practical Generic Function Examples

The identity function is useful for learning, but real projects use generic functions for more practical tasks.

Example 1: Returning the First Item

from typing import TypeVar

T = TypeVar("T")

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

Usage:

first_item([1, 2, 3])

Type checker view:

list[int] → int

Usage:

first_item(["Alice", "Bob"])

Type checker view:

list[str] → str

The function automatically adapts to the type stored inside the list.

Example 2: Returning the Last Item

from typing import TypeVar

T = TypeVar("T")

def last_item(items: list[T]) -> T:
    return items[-1]

Examples:

last_item([10, 20, 30])

returns:

int

Examples:

last_item(["Alice", "Bob"])

returns:

str

Example 3: Repeating a Value

from typing import TypeVar

T = TypeVar("T")

def repeat(value: T, count: int) -> list[T]:
    return [value] * count

Usage:

repeat("Python", 3)

Type checker view:

str → list[str]

Usage:

repeat(100, 3)

Type checker view:

int → list[int]

Notice how the type information is preserved throughout the function.


4.4 Using Multiple TypeVars

So far, we’ve used a single type variable:

T = TypeVar("T")

However, generic functions can use multiple type variables when they need to represent multiple independent types.

For example:

from typing import TypeVar

K = TypeVar("K")
V = TypeVar("V")

Here:

K = Key Type
V = Value Type

Let’s create a function that returns a pair:

from typing import TypeVar

K = TypeVar("K")
V = TypeVar("V")

def make_pair(key: K, value: V) -> tuple[K, V]:
    return key, value

Usage:

make_pair("id", 100)

Type checker view:

tuple[str, int]

Another example:

make_pair("name", "Alice")

Type checker view:

tuple[str, str]

Notice that K and V are inferred independently.

The first argument determines K.

The second argument determines V.

Why Multiple TypeVars Matter

Many real-world data structures naturally involve multiple types.

Examples include:

dict[K, V]

where:

K = key type
V = value type

and:

tuple[T1, T2]

where each element may have a different type.

Using multiple TypeVars allows generic functions to accurately describe these relationships.

At this point, we’ve learned how to create generic functions and how type variables help preserve type information across function parameters and return values. Next, we’ll move beyond functions and explore how the same ideas can be applied to entire classes using generic classes.


Part 5 — Generic Classes

So far, we’ve used generics to create reusable functions. However, functions are only one part of programming. Many real-world programs also rely heavily on objects and classes.

What if we want to create a reusable class that can safely store different types of data while preserving type information?

This is exactly where generic classes become useful.

In fact, many of Python’s built-in collection types and modern libraries rely extensively on generic classes behind the scenes.


5.1 Why Generic Functions Are Not Enough

Generic functions solve the problem of reusable behavior.

For example:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

This function can work with many different types while preserving type information.

However, functions are not the only things that need to be reusable.

Imagine we want to create a simple container object that stores a value.

Without generics, we might write:

class IntBox:
    def __init__(self, value: int):
        self.value = value

Then later:

class StringBox:
    def __init__(self, value: str):
        self.value = value

And perhaps:

class UserBox:
    def __init__(self, value: User):
        self.value = value

Clearly this becomes repetitive.

The structure of the class remains identical.

Only the stored type changes.

This is the same problem we encountered with generic functions:

We want reusable code without losing type information.

Generic classes solve this problem.


5.2 Introducing Generic[T]

To create a generic class, Python provides the Generic base class.

It is imported from the typing module:

from typing import Generic, TypeVar

Then we create a type variable:

T = TypeVar("T")

Finally, we inherit from:

Generic[T]

Example:

class Box(Generic[T]):
    ...

The meaning is:

This class is generic and uses the type variable T.

Just as a generic function allows the caller to choose a type, a generic class allows the user of the class to choose a type.

For example:

Box[int]

or:

Box[str]

or:

Box[User]

The class structure remains the same while the contained type changes.


5.3 Creating Your First Generic Class

Let’s create a simple generic container.

from typing import Generic, TypeVar

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, value: T):
        self.value = value

Now we can create typed boxes:

number_box = Box[int](100)

text_box = Box[str]("Python")

The type checker understands:

number_box.value -> int

text_box.value -> str

Even though both objects use the same class.

How It Works

When we write:

Box[int]

the type checker substitutes:

T = int

throughout the class.

Conceptually, it becomes:

class Box:
    value: int

Similarly:

Box[str]

causes:

T = str

throughout the class.

The actual runtime class remains the same.

Only the type checker’s understanding changes.


5.4 Generic Container Examples

The Box example is useful for learning, but let’s look at more practical examples.

Example 1: Stack[T]

A stack stores multiple values of the same type.

from typing import Generic, TypeVar

T = TypeVar("T")

class Stack(Generic[T]):
    def __init__(self):
        self.items: list[T] = []

    def push(self, item: T) -> None:
        self.items.append(item)

    def pop(self) -> T:
        return self.items.pop()

Usage:

number_stack = Stack[int]()

number_stack.push(10)
number_stack.push(20)

The type checker knows:

All items must be int

Attempting:

number_stack.push("hello")

would produce a type-checking error.

Example 2: Pair[K, V]

Sometimes a class needs multiple type parameters.

from typing import Generic, TypeVar

K = TypeVar("K")
V = TypeVar("V")

class Pair(Generic[K, V]):
    def __init__(self, key: K, value: V):
        self.key = key
        self.value = value

Usage:

user_pair = Pair[str, int]("id", 100)

The type checker understands:

key   -> str
value -> int

Each type variable is tracked independently.

Example 3: Repository[T]

Many web frameworks and database libraries use patterns similar to this:

from typing import Generic, TypeVar

T = TypeVar("T")

class Repository(Generic[T]):
    def save(self, item: T) -> None:
        ...

    def get(self) -> T:
        ...

Usage:

user_repository = Repository[User]()

product_repository = Repository[Product]()

The same repository structure can now work with different models while preserving type safety.


5.5 Generic Methods Inside Classes

Generic classes often contain methods that use the class’s type variable.

For example:

from typing import Generic, TypeVar

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, value: T):
        self.value = value

    def get_value(self) -> T:
        return self.value

Notice that the method also uses T.

If:

box = Box[str]("Python")

then:

box.get_value()

is understood by the type checker as returning:

str

Likewise:

box = Box[int](100)

causes:

box.get_value()

to return:

int

The method automatically inherits the class’s type parameter.

Important

You do not need to create a new TypeVar for every method.

Most methods simply reuse the generic type already defined by the class.


Part 6 — Constraints and Bounds

So far, every TypeVar we’ve created has been completely unrestricted.

For example:

from typing import TypeVar

T = TypeVar("T")

Here, T can represent almost any type:

int
str
float
list
dict
User
Product

and many others.

This flexibility is useful, but sometimes it is too flexible.

What if a function only makes sense for certain types?

Or what if we need a type that supports a particular method or behavior?

This is where constraints and bounds become useful.

Both features allow us to place restrictions on a TypeVar, but they work in very different ways.

Understanding that difference is one of the most important concepts in advanced generic programming.


6.1 Type Constraints

A constrained TypeVar limits a type variable to a specific set of allowed types.

The syntax looks like this:

from typing import TypeVar

T = TypeVar("T", int, float)

Here we’re telling the type checker:

T can only be int or float.

No other types are allowed.

Example

from typing import TypeVar

T = TypeVar("T", int, float)

def double(value: T) -> T:
    return value * 2

Valid calls:

double(10)
double(3.14)

Invalid call:

double("Hello")

The type checker reports an error because:

str

is not one of the permitted types.

Think of Constraints as a Whitelist

A constrained TypeVar behaves like a whitelist.

T = TypeVar("T", int, float)

means:

Allowed:
✓ int
✓ float

Not Allowed:
✗ str
✗ bool
✗ list
✗ User

Only the explicitly listed types can be used.

Can a TypeVar Have Only One Constraint?

When learning constrained TypeVars, some readers naturally try:

from typing import TypeVar

T = TypeVar("T", int)

However, this raises an error:

TypeError: A single constraint is not allowed

Why?

Because constraints are designed to provide a choice between multiple possible types.

For example:

T = TypeVar("T", int, float)

means:

T can be either int or float.

The type checker must choose between multiple allowed types.

With only one constraint:

T = TypeVar("T", int)

there is no choice to make.

The type variable can only ever be:

int

In that situation, using a TypeVar would provide no benefit.

You can simply write:

def double(value: int) -> int:
    return value * 2

which is clearer and easier to understand.

Can a TypeVar Have More Than Two Constraints?

Yes.

Although two constraints are the minimum, a TypeVar may contain any number of allowed types.

For example:

from typing import TypeVar

T = TypeVar("T", int, float, str)

This means:

T can be int, float, or str.

Similarly:

from typing import TypeVar

T = TypeVar("T", int, float, str, bool)

means:

T can be int, float, str, or bool.

The constraint list acts as a whitelist of permitted types.

Allowed Types:
✓ int
✓ float
✓ str
✓ bool

Everything else is rejected:

✗ list
✗ dict
✗ set
✗ User

Important

Constraints are not inheritance-based.

If a type is not explicitly listed, it is not accepted.

The type checker treats the constraint list as the complete set of valid choices.


6.2 Bounded TypeVars

Constraints and bounds may look similar at first, but they solve different problems.

A constrained TypeVar limits a type variable to a fixed set of specific types:

T = TypeVar("T", str, bytes)

A bounded TypeVar limits a type variable to a base class and its subclasses:

T = TypeVar("T", bound=Animal)

Syntax

from typing import TypeVar

T = TypeVar("T", bound=Animal)

This means:

T must be Animal or any class that inherits from Animal.

Example

from typing import TypeVar

class Animal:
    def speak(self) -> str:
        return "..."

class Dog(Animal):
    pass

class Cat(Animal):
    pass

T = TypeVar("T", bound=Animal)

def make_sound(animal: T) -> str:
    return animal.speak()

Valid:

make_sound(Dog())
make_sound(Cat())

Invalid:

make_sound("Hello")

because:

str

does not inherit from:

Animal

Think of Bounds as an Inheritance Rule

When you write:

T = TypeVar("T", bound=Animal)

you are telling the type checker:

I don’t care which specific animal this is, as long as it belongs to the Animal family.

Allowed:

✓ Animal
✓ Dog
✓ Cat
✓ Any future Animal subclass

Not Allowed:

✗ str
✗ int
✗ list

Unlike constraints, the set of valid types is not fixed.

If someone later creates:

class Bird(Animal):
    pass

it automatically becomes a valid type for T.

Why Bounds Are Useful

Often a function only needs access to methods provided by a base class.

For example:

class Animal:
    def speak(self) -> str:
        ...

A function that calls:

animal.speak()

doesn’t care whether it receives a:

Dog
Cat
Bird

or some subclass that hasn’t even been written yet.

The only requirement is that the object behaves like an Animal.

A bounded TypeVar lets you express exactly that idea.


6.3 Constraints vs Bounds

At first glance, constraints and bounds appear similar because both restrict a TypeVar.

However, they solve different problems.

Constraints

T = TypeVar("T", int, float)

Means:

T must be one of these exact types.

Bounds

T = TypeVar("T", bound=Shape)

Means:

T must inherit from this base class.

Side-by-Side Comparison

FeatureConstraintsBounds
Restriction StyleExplicit type listBase class
Uses InheritanceNoYes
Future Subclasses AllowedNoYes
Exact Types RequiredYesNo
Typical Use CaseSmall fixed set of typesFamily of related types

Visual Comparison

This infographic provides a side-by-side comparison to help you quickly understand when to use each approach.

Infographic comparing Python TypeVar constraints and bounds. The left side explains constrained TypeVars using examples such as TypeVar("T", str, bytes), while the right side explains bounded TypeVars using TypeVar("T", bound=Animal). The infographic highlights syntax, valid and invalid examples, inheritance relationships, key differences, and practical usage guidelines.

As a simple rule, use constraints when you want to restrict a type variable to a fixed set of specific types, and use bounds when you want to accept a base class along with any of its subclasses.


Part 7 — Understanding AnyStr

Throughout this lesson, we’ve learned how generic programming uses TypeVar to create relationships between types. We’ve also explored constrained TypeVars, which restrict a type variable to a specific set of allowed types.

One of the most famous real-world examples of a constrained TypeVar is AnyStr.

If you’ve read Python’s typing documentation, explored older codebases, or looked at type hints in popular libraries, you’ve probably encountered it before. At first glance, it may seem like just another special type hint, but AnyStr was created to solve a very specific problem involving text data.

Understanding AnyStr not only helps you read existing code, but also reinforces several important generic programming concepts you’ve already learned.


7.1 What Is AnyStr?

AnyStr is a predefined type variable provided by the typing module.

from typing import AnyStr

Internally, it behaves roughly like this:

from typing import TypeVar

AnyStr = TypeVar("AnyStr", str, bytes)

As you learned in Part 6, this is a constrained TypeVar.

It means:

AnyStr can be:
✓ str
✓ bytes

and nothing else.

For example:

from typing import AnyStr

def identity(value: AnyStr) -> AnyStr:
    return value

Valid calls:

identity("Hello")
identity(b"Hello")

Invalid calls:

identity(100)
identity(3.14)
identity(["Hello"])

because AnyStr only allows str and bytes.

A Quick Reminder

At this point, you already know how constrained TypeVars work.

The interesting question is not:

“How is AnyStr implemented?”

The more important question is:

“Why was AnyStr created in the first place?”

To answer that, we need to understand the problem it was designed to solve.


7.2 The Problem AnyStr Solves

Python has two common text-related types:

str
bytes

Many functions can work with either of them.

Suppose we write:

def process(data: str | bytes) -> str | bytes:
    return data

This looks reasonable.

The function accepts either:

str
bytes

and returns either:

str
bytes

However, something important has been lost.

Consider:

result = process("Hello")

As humans, we know the result must be:

str

because we passed a string.

But the type checker only sees:

str | bytes

Likewise:

result = process(b"Hello")

also produces:

str | bytes

even though the actual return type must be:

bytes

The relationship between the input type and output type has disappeared.

This is exactly the kind of problem generics were designed to solve.


7.3 How It Maintains Type Consistency

Now let’s rewrite the same function using AnyStr.

from typing import AnyStr

def process(data: AnyStr) -> AnyStr:
    return data

This tells the type checker:

Whatever text type goes in must be the same text type that comes out.

When we call:

process("Hello")

the type checker infers:

AnyStr = str

Therefore:

str → str

When we call:

process(b"Hello")

the type checker infers:

AnyStr = bytes

Therefore:

bytes → bytes

The relationship is preserved.

Preventing Mixed Types

Another advantage of AnyStr is that it prevents accidental mixing of strings and bytes.

Consider:

from typing import AnyStr

def concatenate(first: AnyStr, second: AnyStr) -> AnyStr:
    return first + second

Valid:

concatenate("Hello", "World")
concatenate(b"Hello", b"World")

Invalid:

concatenate("Hello", b"World")

Why?

Because the same AnyStr must represent one consistent type during a single function call.

It cannot simultaneously represent:

str

and

bytes

This is the exact same “consistent type” rule we learned earlier with ordinary TypeVars.


Part 8 — PEP 695 Modern Generic Syntax (Python 3.12+)

Throughout this lesson, we’ve used the traditional generic syntax:

from typing import TypeVar

T = TypeVar("T")

and:

from typing import Generic

class Box(Generic[T]):
    ...

This syntax has worked well for many years and remains widely used today. However, as Python’s type system evolved, many developers felt that generic code contained too much boilerplate.

To address this, Python 3.12 introduced PEP 695, which provides a cleaner and more readable way to define generic functions, classes, and type aliases.

If you’re learning modern Python, this is an important feature to understand because you’ll increasingly encounter it in newer codebases and documentation.


8.1 Why PEP 695 Was Introduced

Before Python 3.12, creating generics required several separate steps.

For example:

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

Even for a simple generic function, we needed:

  1. Import TypeVar
  2. Create a TypeVar
  3. Use the TypeVar in the function

The same situation existed for generic classes:

from typing import Generic, TypeVar

T = TypeVar("T")

class Box(Generic[T]):
    ...

Many developers felt this was unnecessarily verbose.

The actual generic information was spread across multiple places in the code.

PEP 695 was introduced to:

  • Reduce boilerplate
  • Improve readability
  • Make generic code easier to write
  • Keep generic type parameters closer to where they are used
  • Make Python’s syntax more consistent with other modern programming languages

The result is a much cleaner syntax for defining generics.


8.2 Modern Generic Functions

Let’s revisit our familiar identity() function.

Traditional Syntax

from typing import TypeVar

T = TypeVar("T")

def identity(value: T) -> T:
    return value

PEP 695 Syntax

def identity[T](value: T) -> T:
    return value

Notice what changed. We don’t need to import anything from typing module.

And also we no longer need:

T = TypeVar("T")

The type parameter is declared directly in the function definition:

def identity[T]

This immediately tells readers:

This is a generic function that uses the type parameter T.

Multiple Type Parameters

Traditional syntax:

from typing import TypeVar

K = TypeVar("K")
V = TypeVar("V")

def make_pair(key: K, value: V) -> tuple[K, V]:
    return key, value

PEP 695 syntax:

def make_pair[K, V](key: K, value: V) -> tuple[K, V]:
    return key, value

The generic parameters are now visible directly in the function signature.

Many developers find this easier to read because everything related to the function appears in one place.


8.3 Modern Generic Classes

PEP 695 also simplifies generic classes.

Traditional Syntax

from typing import Generic, TypeVar

T = TypeVar("T")

class Box(Generic[T]):
    def __init__(self, value: T):
        self.value = value

PEP 695 Syntax

class Box[T]:
    def __init__(self, value: T):
        self.value = value

Again, several pieces of boilerplate disappear:

TypeVar("T")
Generic[T]

The type parameter is declared directly in the class definition.

This makes the generic nature of the class immediately obvious.

Multiple Type Parameters

Traditional syntax:

from typing import Generic, TypeVar

K = TypeVar("K")
V = TypeVar("V")

class Pair(Generic[K, V]):
    ...

PEP 695 syntax:

class Pair[K, V]:
    ...

The more type parameters a class uses, the more noticeable the reduction in boilerplate becomes.


8.4 Old vs New Syntax

Let’s compare the two styles side-by-side.

FeatureTraditional SyntaxPEP 695 Syntax
Generic Functiondef identity(value: T) -> Tdef identity[T](value: T) -> T
Generic Classclass Box(Generic[T])class Box[T]
TypeVar DeclarationRequiredNot Required
Generic ImportOften RequiredUsually Not Needed
BoilerplateMoreLess
ReadabilityGoodOften Better
Minimum Python VersionPython 3.5+Python 3.12+

Constraints in PEP 695

Earlier, we learned constrained TypeVars:

from typing import TypeVar

T = TypeVar("T", int, float)

PEP 695 provides a modern equivalent:

def double[T: (int, float)](value: T) -> T:
    return value * 2

Likewise, bounds can also be expressed directly within the type parameter declaration.

Bounds in PEP 695

Earlier in this lesson, we learned how to create a bounded TypeVar. PEP 695 allows the bound to be declared directly in the type parameter:

Traditional syntax:

from typing import TypeVar

T = TypeVar("T", bound=Animal)

def make_sound(animal: T) -> str:
    return animal.speak()

PEP 695 syntax:

def make_sound[T: Animal](animal: T) -> str:
    return animal.speak()

The behavior is identical. The only difference is that the bound is declared directly within the function’s type parameter list.

A Small Observation

You may notice that both constraints and bounds use the : symbol in PEP 695:

T: Animal
T: (int, float)

The meaning depends on what appears after the colon:

A single type indicates a bound.

T: Animal

A parenthesized list of types indicates constraints.

T: (int, float)

This is one of the reasons many developers find PEP 695 easier to read—the generic type parameter, along with any constraints or bounds, is defined in one place instead of being split across separate TypeVar declarations.


Part 9 — Summary & Key Takeaways

Congratulations! You’ve reached the end of one of the most important topics in modern Python type hinting.
Let’s quickly review everything we’ve learned.

  • Generic types solve the problem of writing reusable code without losing type information.
  • Unlike Any, generics preserve relationships between input and output types.
  • A TypeVar acts as a placeholder type that is chosen by the caller.
  • Generic functions use TypeVar to maintain type consistency across parameters and return values.
  • Type checkers automatically determine the actual type represented by a TypeVar through type inference.
  • Generic classes allow a single class definition to work with many different types.
  • The same generic class can create typed variations such as Box[int], Box[str], and Box[User].
  • A constrained TypeVar restricts allowed types to a fixed set, such as TypeVar("T", int, float).
  • A bounded TypeVar restricts types using inheritance, such as TypeVar("T", bound=Shape).
  • Constraints and bounds solve different problems and should not be treated as interchangeable.
  • AnyStr is a predefined constrained TypeVar that helps maintain consistency between str and bytes.
  • Modern Python (3.12+) introduces PEP 695, which provides a cleaner syntax for generic functions and classes.
  • The traditional TypeVar syntax remains important because it is still widely used in existing codebases.
  • Most built-in collection types, such as list[str] and dict[str, int], are themselves examples of generic types.

Key Takeaway

The most important idea from this lesson is that generics are not primarily about supporting multiple types. They are about preserving type relationships while keeping code flexible, reusable, and type-safe.


Conclusion

When developers first encounter generic types, they often focus on the syntax:

T = TypeVar("T")

or:

class Box(Generic[T]):
    ...

But generics are not really about learning new syntax. They are about preserving type information while keeping code reusable.

Before generics, we often had to choose between flexibility and type safety. We could use Any and lose valuable type information, or create multiple versions of the same function and class for different types. Generic types solve this problem by allowing us to write code once and safely reuse it with many different types.

Throughout this lesson, you’ve learned how TypeVar works, how generic functions and classes preserve type relationships, the difference between constraints and bounds, why AnyStr exists, and how PEP 695 introduces a cleaner modern syntax for generics.

One of the most important ideas to remember is that generics are not primarily about supporting multiple types. Their real purpose is preserving relationships between types.

By understanding generics, you’ve taken another major step toward mastering Python’s type hinting system and reading the kind of advanced type annotations commonly found in modern libraries and real-world codebases. 🐍


Hi, I’m Ankur, the creator of PyCoderHub. I document my Python learning journey in a structured, beginner-friendly way to make concepts clear and easy to follow.

Each post is carefully researched, cross-checked, and simplified to ensure accurate explanations. If you’re learning Python, you can follow along step by step—and if you’re experienced, your feedback is always welcome.

2 thoughts on “Python Generic Types Explained: TypeVar, Generics, Constraints & PEP 695

Leave a Reply

Your email address will not be published. Required fields are marked *