Skip to main content

Introduction to Generics and Type Variables

Imagine you've written a function that gets the first item from a list of integers. It works perfectly:

def get_first_int(items: list[int]) -> int | None:
return items[0] if items else None

Now you need the same function for strings. So you write another one:

def get_first_str(items: list[str]) -> str | None:
return items[0] if items else None

And another for custom User objects. And another for Book objects. You're copying the same logic over and over, just changing the type names. This is the duplication problem that Generics solve.

Generics let you write ONE function that works with ANY type while keeping full type safety. Instead of three separate functions, you get this:

def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

One function. Works with integers, strings, User objects, anything. And your IDE knows the types—it autocompletes correctly and catches type errors before you run the code.

This is the power of Generics: reusable type-safe code. In this lesson, you'll learn to write generic functions that scale to any data type while preserving the type information that makes your IDE smarter.

What Are Generics? Type-Safe Code That Works with Any Type

Before we dive into syntax, let's understand the problem Generics solve. In Python, you have two options for flexible functions:

Option 1: No Type Hints

def get_first_item(items: list) -> None:
return items[0] if items else None

This works but loses all type information. Your IDE has no idea what type the item is. Did you get an int? A string? A User? The IDE can't help you—you're flying blind.

Option 2: Use Any Type

from typing import Any

def get_first_item(items: list[Any]) -> Any:
return items[0] if items else None

This says "this could be anything," which technically works but throws away type information. Your IDE won't autocomplete. If you try to call .upper() on the result, the IDE won't know if it's safe.

Option 3: Generics (The Right Way)

def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

Now the function says: "I accept any type T, and I return the same type T." When you call get_first_item([1, 2, 3]), the IDE knows T is int, so it knows the return type is int | None. When you call get_first_item(["a", "b"]), the IDE knows T is str, so it knows the return type is str | None.

Why Python needs Generics: Python is dynamically typed at runtime, but professionally-written Python uses static type checking with tools like mypy and Pylance (in your IDE). Generics bridge this gap: they preserve type information for tools while allowing flexible, reusable code.

💬 AI Colearning Prompt

"Ask your AI: What's the difference between using Generics and using Any type in Python? Show examples of why Generics are better."


Section 1: Your First Generic Function

Let's build your first generic function and see how it preserves type information across different data types.

Creating a Generic Function

Here's the canonical example—get_first_item—that works with any type:

def get_first_item[T](items: list[T]) -> T | None:
"""Returns the first item from a list or None if empty.

Type parameter T ensures the return type matches the input type.
"""
return items[0] if items else None


# Works with integers
numbers: list[int] = [1, 2, 3]
first_num = get_first_item(numbers) # Type: int | None

# Works with strings
names: list[str] = ["Alice", "Bob"]
first_name = get_first_item(names) # Type: str | None

# Works with custom objects
class Book:
def __init__(self, title: str):
self.title = title

books: list[Book] = [Book("Python Guide"), Book("Web Dev")]
first_book = get_first_item(books) # Type: Book | None

What's happening here?

  • T is a type variable—a placeholder for any type
  • When you call get_first_item(numbers), Python and your IDE infer that T = int
  • When you call get_first_item(names), Python and your IDE infer that T = str
  • The function works identically in all cases, but the type information flows through

How IDEs Use This Type Information

Your IDE uses Generics to provide autocomplete and error detection:

# IDE knows first_num is int | None
if first_num is not None:
# IDE offers methods for int: bit_length(), to_bytes(), etc.
print(first_num + 10) # ✅ IDE allows this (int + int is valid)
print(first_num.upper()) # ❌ IDE flags this error (int has no .upper())

# IDE knows first_name is str | None
if first_name is not None:
# IDE offers methods for str: upper(), lower(), split(), etc.
print(first_name.upper()) # ✅ IDE allows this (str has .upper())
print(first_name + 10) # ❌ IDE flags this error (str + int invalid)

The IDE catches bugs BEFORE you run the code. This is the real value of Generics—not just for runtime, but for your development experience.

🎓 Instructor Commentary

Generics preserve type information; Any throws it away. Generics = safety, Any = wild west. In professional Python, you almost never use Any when Generics are available. The goal: keep the IDE as your co-pilot.


Section 2: Modern PEP 695 Syntax

Python has two ways to write generics. One is old and verbose. One is modern and clean. You're already seeing the modern way—let's understand why.

Before Python 3.14, you had to import TypeVar from the typing module:

from typing import TypeVar

T = TypeVar('T') # Create a type variable

def get_first_item(items: list[T]) -> T | None:
return items[0] if items else None

This works, but it's awkward. You define T separately from the function. You have to remember to import TypeVar. It's extra boilerplate.

The Modern Approach (PEP 695 - Python 3.14+)

Python 3.14 introduced cleaner syntax:

def get_first_item[T](items: list[T]) -> T | None:
"""Returns the first item from a list or None if empty."""
return items[0] if items else None

The [T] goes directly in the function definition. No imports, no separate TypeVar definition. It's cleaner, more readable, and the direction Python is evolving.

Key difference:

  • Legacy: TypeVar lives outside the function; function uses it
  • Modern: Type parameters declared right in the function signature

For this book, always use PEP 695 syntax. It's simpler, more readable, and future-proof.

✨ Teaching Tip

Always use PEP 695 syntax in Python 3.14+. It's simpler, more readable, and represents the future of Python's type system. If you see legacy TypeVar imports in old code, recognize them but write modern code.


Section 3: Type Inference in Action

One of the elegant features of Generics is that Python infers the type automatically. You don't have to tell Python what T is—it figures it out from how you call the function.

Python Infers T from Context

# Call 1: Python sees list[int] and infers T = int
result1 = get_first_item([1, 2, 3]) # T is inferred as int

# Call 2: Python sees list[str] and infers T = str
result2 = get_first_item(["hello", "world"]) # T is inferred as str

# Call 3: Python sees list[Book] and infers T = Book
result3 = get_first_item([Book("A"), Book("B")]) # T is inferred as Book

You never explicitly tell Python what T is. It infers it from the argument you pass. This is why the function "just works" with any type.

Explicit Type Parameters (Rare)

In very rare cases, you might need to explicitly specify the type parameter:

# Explicit specification (rarely needed)
result = get_first_item[int]([1, 2, 3])

This says "I'm explicitly telling you that T is int." In practice, inference handles 99% of cases. You'll almost never use explicit type parameters.

Type Checkers Use Generics to Catch Errors

Your IDE and type checkers like mypy and Pylance use Generics to validate your code before running it:

numbers: list[int] = [1, 2, 3]
first = get_first_item(numbers) # Type checker knows this is int | None

# This is safe (int supports +)
if first is not None:
value = first + 10 # ✅ Type checker allows this

# This is an error (int doesn't support .upper())
if first is not None:
text = first.upper() # ❌ Type checker flags this as error

The type checker runs without executing code. It catches the .upper() error immediately in your IDE.

🚀 CoLearning Challenge

Tell your AI: "Create a generic function get_last_item[T] that returns the last element from a list or None if empty. Show me usage examples with both integers and strings, and explain how type information flows through."

Expected Outcome: You'll see a generic function that mirrors get_first_item but accesses the last element, demonstrating that the generic pattern works for any container operation.


Section 4: Generics vs Dynamic Typing

Here's an important distinction that clarifies what Generics actually do:

Generics are for tools (IDEs, type checkers), not for Python runtime.

Python is still dynamically typed when your code runs. Generics don't enforce type checking at runtime—they provide type information for static analysis tools.

Generics Don't Enforce Runtime Type Checking

def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

# At runtime, this works (Python is dynamically typed)
weird_list: list[int] = [1, 2, 3] # Type says this is list[int]
first = get_first_item(weird_list) # Works fine at runtime

# Python doesn't actually check that items is list[int]
# If you pass list[str], Python runs it anyway (runtime is dynamic)

This is important: Generics are NOT runtime enforcement. They're hints for tools.

The Real Benefit: Catch Errors Before Running Code

The value of Generics is in your development workflow:

numbers: list[int] = [1, 2, 3]
first = get_first_item(numbers)

# IDE knows first is int | None
if first is not None:
text = first.upper() # ❌ IDE shows red squiggly (int has no .upper())

You see the error in your editor BEFORE running the code. You fix it BEFORE it becomes a production bug. This is the real power—catch mistakes during development, not after deployment.

💬 AI Colearning Prompt

"Ask your AI: Create a generic function with a type mismatch bug (where operations don't match the type). Show how an IDE using type hints would catch this error before running."


Common Mistakes

Mistake 1: Using Any When Generics Are Better

# ❌ Don't do this (throws away type information)
from typing import Any
def get_first_item(items: list[Any]) -> Any:
return items[0] if items else None

# ✅ Do this (preserves type information)
def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

Any is a cop-out. It says "I don't care about type safety." Generics say "I maintain type safety for any type."

Mistake 2: Over-Constraining with Unnecessary Type Bounds

You might think you need to constrain what types are allowed:

# ❌ Overly restrictive (unnecessary bounds)
from typing import Sequence
def get_first[T: Sequence](items: list[T]) -> T | None: # Why constrain T?
return items[0] if items else None

# ✅ Simple and flexible
def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

If your function doesn't require special methods on T, don't constrain it. Let it work with anything.

Mistake 3: Thinking Generics Enforce Runtime Type Checking

def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

# ❌ Mistaken expectation
mixed_list = [1, "two", 3.0] # Not actually a valid type at runtime
first = get_first_item(mixed_list) # Python doesn't enforce T consistency at runtime

Generics don't enforce types at runtime. Python is still dynamically typed. Generics help your IDE, not the runtime.

Mistake 4: Mixing Legacy and Modern Syntax

# ❌ Don't mix them (confusing)
from typing import TypeVar
T = TypeVar('T')
def get_first[T](items: list[T]) -> T | None: # This mixes styles
return items[0] if items else None

# ✅ Use modern syntax exclusively
def get_first_item[T](items: list[T]) -> T | None:
return items[0] if items else None

Pick one style and stick with it. Modern PEP 695 is cleaner.


Try With AI

Now let's practice Generics with your AI companion. These prompts progress from understanding concepts to creating new generic functions.

Prompt 1: Understand Why Generics Matter

Ask your AI: "Why do we need Generics when Python is dynamically typed? Explain with 2-3 concrete examples of bugs that Generics help prevent."

Expected Outcome: An explanation showing how Generics enable IDE autocomplete, help catch type mismatches before running code, and make refactoring safer. Examples might include "calling string methods on integers" or "passing wrong types to functions."


Prompt 2: Apply Generics to Create a Search Function

Tell your AI: "Create a generic function find_item[T](items: list[T], predicate: callable) -> T | None that finds the first item in a list matching a condition (predicate function). Show usage with both integer and User types, demonstrating how type information is preserved in each case."

Expected Outcome: A working generic search function with:

  • Generic function signature using PEP 695 syntax
  • Type preservation across different data types (int, User, etc.)
  • Usage examples showing IDE type inference working correctly
  • Docstring explaining the type parameter

Prompt 3: Analyze Tradeoffs—Generics vs Any

Ask your AI: "Compare using Generics vs using Any type. Create a side-by-side comparison showing what each approach loses or gains. When would you actually use Any instead of Generics?"

Expected Outcome: A comparison table or explanation covering:

  • Generics: Type safety, IDE support, clear contracts but require type parameter specification
  • Any: Maximum flexibility but loses type information and IDE help
  • Real scenarios where Any might be acceptable (very rare)

Prompt 4: Create a Generic Merge Function

Tell your AI: "Write a generic function merge_sorted_lists[T](list1: list[T], list2: list[T]) -> list[T] that combines two sorted lists into one sorted list. The function should work with any type (numbers, strings, etc.). Include type hints and demonstrate with both integers and strings, showing that the type information is preserved in the return value."

Expected Outcome: A generic merge function with:

  • Correct PEP 695 syntax with type parameters
  • Full type hints on all parameters and return type
  • Implementation that works for any type T
  • Demonstration with multiple types (int, str, etc.)
  • Examples showing type preservation in usage