Typing in Python

Typing is a feature in Python that allows developers to specify the expected data types of variables, function parameters, and return values. While Python remains a dynamically typed language at runtime, type annotations provide a way to document and statically verify the contracts between different parts of your code. This practice significantly improves code quality, readability, and long-term maintainability.


Why Use Typing?

  1. Improved Readability: By explicitly stating the expected data types, it becomes easier for developers to understand the purpose of variables and functions at a glance. Type annotations act as inline documentation that never goes out of date.
  2. Early Error Detection: Typing helps catch type-related errors during development—before your code ever runs—reducing the likelihood of runtime errors and improving code reliability.
  3. Enhanced Tooling Support: Modern code editors and IDEs (such as VS Code, PyCharm) provide significantly better auto-completion, linting, and refactoring support when type annotations are present.
  4. Better Documentation: Type annotations serve as a form of living documentation, making it immediately clear what types of data are expected and returned by functions—without needing to read the implementation.

Chapter 1: Basic Type Annotations

At its core, typing in Python involves annotating variables, function parameters, and return values with their expected types. Python provides several built-in types that can be used directly as annotations.

Primitive Types

TypeDescription
intRepresents an integer (e.g., 42, -7)
floatRepresents a floating-point number (e.g., 3.14)
strRepresents a string (e.g., "hello")
boolRepresents a boolean value (True or False)
NoneRepresents the absence of a value
AnyRepresents any type (disables type checking)

Annotating Variables

name: str = "Alice"
age: int = 30
height: float = 5.9
is_active: bool = True

Annotating Functions

The most common use of typing is in function signatures. You annotate each parameter with its expected type and specify the return type using the -> syntax.

def greet(name: str) -> str:
    return f"Hello, {name}!"

print(greet("Alice"))  # Output: Hello, Alice!

In this example, the greet function is annotated to indicate that it expects a string parameter name and returns a string. This makes it clear to anyone reading the code what types of data are involved, and a type checker will flag an error if someone tries to pass a non-string argument.

More Examples

def add(a: int, b: int) -> int:
    return a + b

print(add(5, 3))  # Output: 8

When a function performs an action but does not return a meaningful value, annotate its return type as None:

def do_something() -> None:
    print("done")

do_something()  # Output: done

Chapter 2: object vs Any

These two types may appear similar at first glance—both can hold any value—but they behave very differently from a type-checking perspective. Understanding this distinction is fundamental before moving forward.

object

object is the most general type in Python’s type hierarchy. Every class inherits from object. When you annotate a variable as object, you are saying: “This can hold any value, but I do not know what specific type it is.” As a consequence, the type checker will not allow you to call any type-specific methods on it without first verifying its type.

y: object = "Hello"
# print(y.upper())  # Error: 'object' has no attribute 'upper'

if isinstance(y, str):
    print(y.upper())  # Output: HELLO

This is the safe approach: you must prove the type before performing operations.

Any

Any is a special type from the typing module that effectively disables type checking for that variable. When you annotate a variable as Any, the type checker allows all operations on it without any verification.

from typing import Any

y: Any = "Hello"
print(y.upper())  # Output: HELLO (no error, no check)

Comparison Table

BehaviorobjectAny
Calls allowed without type checkNoYes
SafetyHighNone
Type checker protectionStrongDisabled
Runtime crash possibleYes (but caught early by typing)Yes (but not warned)

Best Practice: Prefer object over Any whenever possible. Use Any only as a last resort when the type truly cannot be determined, and understand that you are opting out of type safety entirely.


Chapter 3: Collection Type Annotations

Python’s built-in collection types can be parameterized with the types of their elements, allowing the type checker to verify that you are using them correctly.

list — Ordered, Mutable Sequence

A list annotation specifies the type of items the list may contain. You do not need to specify the number of items—only the element type.

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

This means that numbers is a list of integers. The type checker will flag an error if you attempt to append a string to this list.

tuple — Fixed-Size, Immutable Sequence

Unlike list, a tuple annotation specifies both the number of items and the type of each item. This is because tuples are typically used to represent fixed-size records where each position has a specific meaning.

point: tuple[float, float] = (1.0, 2.0)
name_and_age_dob: tuple[str, int, str] = ("Alice", 30, "01-01-1990")

Here, point is a tuple containing exactly two floats, and name_and_age_dob is a tuple containing a string, an integer, and another string—in that exact order.

Variable-length tuples: If you want a tuple that can contain an arbitrary number of items of the same type, use the ellipsis (...) syntax:

scores: tuple[int, ...] = (1, 2, 3, 4, 5)

This declares scores as a tuple containing any number of integers.

dict — Key-Value Mapping

A dict annotation specifies the types of its keys and values:

details: dict[str, int] = {"age": 30, "height": 170}

This means that details is a dictionary where all keys are strings and all values are integers.

set — Unordered Collection of Unique Items

A set annotation specifies the type of items it contains:

unique_numbers: set[int] = {1, 2, 3, 4, 5}

This declares unique_numbers as a set of integers. Since sets enforce uniqueness, duplicate values will be automatically removed.

Key Difference: In tuple, you must specify the type of each position (or use ... for variable-length). In list, dict, and set, you only specify the element (or key/value) types—not the count.

Practice Example

def greet(names: list[str]) -> str:
    return f"Hello, {', '.join(names)}!"

print(greet(["Alice", "Bob", "Charlie"]))  # Output: Hello, Alice, Bob, Charlie!

Chapter 4: Union and Optional Types

Union (The | Operator)

Sometimes a variable or parameter legitimately needs to accept more than one type. Python 3.10+ introduced the | syntax (equivalent to the older Union from the typing module) to express this.

x: int | str = 42
y: int | str = "Hello"

In this example, both x and y can be either an integer or a string.

With collections:

list_of_numbers: list[int | float] = [1, 2.5, 3, 4.0]

This list can contain both integers and floating-point numbers.

In function signatures:

def process_data(data: dict[str, int | float]) -> None | str:
    if not data:
        return None
    else:
        return f"Processed {len(data)} items."

data1 = {"a": 1, "b": 2.5, "c": 3}

Optional (Union with None)

A very common pattern is to have a value that is either of a specific type or None. This is called an optional type, expressed using | None:

y: int | None = None

This means that y can either be an integer or None. This is useful for cases where a variable may not have a value assigned, or when a function may return None to indicate the absence of a result.

Real-world example:

def find_user(username: str) -> dict[str, str] | None:
    users = {
        "alice": {"name": "Alice", "email": "[email protected]"},
        "bob": {"name": "Bob", "email": "[email protected]"}
    }
    return users.get(username)

user_info = find_user("alice")

Note: X | None is equivalent to the older Optional[X] from typing. The | syntax is preferred in modern Python (3.10+).


Chapter 5: Reusability & Clean Types (Type Aliases)

When you find yourself repeating complex type annotations throughout your code, it is both cumbersome and error-prone. Python provides a way to define type aliases that give meaningful names to complex types.

Defining a Type Alias

type JsonType = dict[str, int | str | bool]

Now you can use JsonType wherever you need to refer to this complex type:

data: JsonType = {"name": "Alice", "age": 30, "is_active": True}

This improves readability and ensures consistency—if the underlying type ever needs to change, you only update it in one place.

Note: The type statement syntax was introduced in Python 3.12. For earlier versions, use TypeAlias from the typing module:

from typing import TypeAlias
JsonType: TypeAlias = dict[str, int | str | bool]

Chapter 6: Precise Types

Standard annotations like str or int describe broad categories, but sometimes you need to express tighter constraints. Python’s typing module provides several tools for this purpose.

Literal — Restricting to Specific Values

Literal is used to specify that a variable or parameter can only take on a specific set of values. Think of it as an inline enumeration. This is particularly useful for configuration options, modes, or any scenario where only certain exact values are valid.

from typing import Literal

def set_log_level(level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) -> None:
    print(f"Log level set to: {level}")

set_log_level("DEBUG")   # Output: Log level set to: DEBUG
set_log_level("INFO")    # Output: Log level set to: INFO
# set_log_level("TRACE")  # Type checker error: not a valid literal

Variable annotation:

from typing import Literal

method: Literal["GET", "POST", "PUT", "DELETE"] = "GET"
print(f"HTTP method: {method}")  # Output: HTTP method: GET

method = "POST"
print(f"HTTP method: {method}")  # Output: HTTP method: POST
# method = "PATCH"  # Type checker error

TypedDict — Structured Dictionaries

A normal dict[str, int] annotation says that all keys are strings and all values are integers. But what if different keys have different value types? TypedDict lets you specify the exact key names and the type of each corresponding value—essentially giving your dictionary a schema.

from typing import TypedDict

class User(TypedDict):
    name: str
    age: int
    email: str

user1: User = {"name": "Alice", "age": 30, "email": "[email protected]"}

The type checker will now ensure that any dictionary annotated as User has exactly these keys with the correct value types.

Using TypedDict with functions:

from typing import TypedDict

class User(TypedDict):
    name: str
    age: int
    email: str
    phone: str

def create_user(name: str, age: int, email: str, phone: str) -> User:
    return {"name": name, "age": age, "email": email, "phone": phone}

user2: User = create_user("Bob", 25, "[email protected]", "123-456-7890")

Optional keys: By default, all keys in a TypedDict are required. Use NotRequired to mark specific keys as optional:

from typing import TypedDict, NotRequired

class User(TypedDict):
    name: str
    age: int
    email: NotRequired[str]  # This key is optional

user: User = {"name": "Alice", "age": 30}  # Valid without email

Final — Constants That Cannot Be Reassigned

Final is used to declare that a variable’s value must not change after its initial assignment. This is useful for constants or configuration values that should remain fixed throughout the program’s lifetime.

from typing import Final

PI: Final[float] = 3.14159
print(PI)  # Output: 3.14159

Attempting to reassign a value to PI will result in a type checker error:

PI = 3.14  # Type checker error: cannot reassign a Final variable

Annotated — Attaching Metadata to Types

Annotated allows you to attach arbitrary metadata to type annotations. The metadata has no effect on runtime behavior or type checking by itself—but it can be consumed by external tools, frameworks (such as Pydantic or FastAPI), or custom validation logic.

from typing import Annotated

age: Annotated[int, "Must be a positive integer"] = 30
print(age)  # Output: 30

In a function:

from typing import Annotated

def set_age(age: Annotated[int, "Must be a positive integer"]) -> None:
    if age <= 0:
        raise ValueError("Age must be a positive integer")
    print(f"Age set to: {age}")

set_age(30)   # Output: Age set to: 30
set_age(-5)   # Raises ValueError: Age must be a positive integer

Note: Annotated is heavily used in frameworks like FastAPI and Pydantic where the metadata is used for input validation, dependency injection, and API documentation generation.


Chapter 7: Generic Types

Sometimes you want to write a function or a class that works with any type while still preserving the relationship between input and output types. This is where generics come in.

TypeVar — Defining a Type Variable

TypeVar creates a placeholder for a type that will be determined when the function is actually called:

from typing import TypeVar

T = TypeVar('T')

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

print(identity(42))       # Output: 42 — T is inferred as int
print(identity("Hello"))  # Output: Hello — T is inferred as str

The key insight is that the return type is guaranteed to match the input type. If you pass an int, you get an int back—not just “any value.”

Using TypeVar with Collections

Generics are especially powerful with collections:

from typing import TypeVar

T = TypeVar('T')

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

print(get_first_item([1, 2, 3]))          # Output: 1 — returns int
print(get_first_item(["a", "b", "c"]))    # Output: a — returns str

Real-World Example

from typing import TypeVar

T = TypeVar('T')

def get_item_at(items: list[T], index: int) -> T:
    return items[index]

print(get_item_at([1, 2, 3], 1))          # Output: 2
print(get_item_at(["a", "b", "c"], 0))    # Output: a

In this example, the type checker knows that get_item_at([1, 2, 3], 1) returns an int, and get_item_at(["a", "b", "c"], 0) returns a str—all without you having to write two separate functions.


Chapter 8: Object-Oriented Programming with Typing

Type annotations integrate seamlessly with Python’s class system, helping to document and verify the structure of your objects.

Typing in Class Attributes

class User:
    name: str = "Alice"
    age: int = 30
    email: str = "[email protected]"

Typing in Method Parameters and Return Values

class User:
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name = name
        self.age = age
        self.email = email

Typing in Instance Attributes

You can also annotate instance attributes directly within __init__:

class User:
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name: str = name
        self.age: int = age
        self.email: str = email

Returning Self in Methods (Method Chaining)

When a method returns the instance itself (for method chaining), use Self from the typing module:

from typing import Self

class User:
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name = name
        self.age = age
        self.email = email

    def update_email(self, new_email: str) -> Self:
        self.email = new_email
        return self

Returning Class Instances in Methods

When a method creates and returns a new instance of the same class, use the class name as the return type. The from __future__ import annotations statement enables forward references so the class name can be used before it is fully defined:

from __future__ import annotations

class User:
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name = name
        self.age = age
        self.email = email

    def create_friend(self, friend_name: str, friend_age: int, friend_email: str) -> User:
        return User(friend_name, friend_age, friend_email)

Taking Class Instances as Parameters

from __future__ import annotations

class User:
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name = name
        self.age = age
        self.email = email

    def is_friend(self, other_user: User) -> bool:
        return self.email == other_user.email

Data Classes with Typing

A dataclass (introduced in Python 3.7 via the dataclasses module) is a decorator that automatically generates boilerplate methods like __init__, __repr__, and __eq__ based on your class’s type-annotated fields. This eliminates repetitive code while keeping full typing support.

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
    email: str

This single declaration is equivalent to writing all of the following manually:

class User:
    def __init__(self, name: str, age: int, email: str) -> None:
        self.name = name
        self.age = age
        self.email = email

    def __repr__(self) -> str:
        return f"User(name={self.name}, age={self.age}, email={self.email})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, User):
            return NotImplemented
        return self.name == other.name and self.age == other.age and self.email == other.email

Chapter 9: Type Narrowing

Type narrowing is the process by which the type checker infers a more specific type for a variable based on conditional checks in your code. This is essential when working with union types.

The Problem

Consider the following function:

def process_value(value: int | str) -> None:
    print(value + 10)

process_value(5)       # Output: 15
process_value("Hello") # TypeError: can only concatenate str (not "int") to str

The function accepts both int and str, but the operation value + 10 only makes sense for integers. The type checker will flag this as an error because it cannot guarantee that value is an int at that point.

The Solution

By using a conditional check (such as isinstance), you narrow the type within that branch. After the check, the type checker knows the exact type of the variable:

def process_value(value: int | str) -> None:
    if isinstance(value, int):
        print(value + 10)    # Type checker knows value is int here
    else:
        print(value.upper()) # Type checker knows value is str here

Common Narrowing Techniques

TechniqueExampleNarrows to
isinstance()if isinstance(x, int):int
is not Noneif x is not None:Removes None
assertassert isinstance(x, str)str
callable()if callable(x):Callable type
Truthiness checkif x:Non-falsy type

Chapter 10: Type Guards

Type narrowing with isinstance() works well for simple cases, but what about complex scenarios where you need custom logic to determine a type? For instance, how do you tell the type checker that a list[object] is actually a list[str] after verifying its contents? This is where Type Guards come in.

What is a Type Guard?

A Type Guard is a user-defined function that tells the static type checker: “If this function returns True, then the argument is of a more specific type.” It was introduced by PEP 647 in Python 3.10 and uses TypeGuard as the return type annotation.

Important: TypeGuard is strictly a static analysis tool. It does not perform any runtime type enforcement on its own—your function’s logic must still use standard Python checks (isinstance, hasattr, etc.) to actually verify the type at runtime.

Basic Example

from typing import TypeGuard

def is_str_list(val: list[object]) -> TypeGuard[list[str]]:
    """Check if all elements in the list are strings."""
    return all(isinstance(x, str) for x in val)

def process_data(data: list[object]) -> None:
    if is_str_list(data):
        # Type checker now knows 'data' is list[str] here
        print(", ".join(data))
    else:
        # data is still list[object] here
        print("Data contains non-string items")

process_data(["hello", "world"])  # Output: hello, world
process_data(["hello", 42])      # Output: Data contains non-string items

Without TypeGuard, the type checker would complain about ", ".join(data) because it only knows data is list[object], and join() requires list[str].

How It Works

  1. Define a function that accepts the value you want to narrow.
  2. Annotate the return type as TypeGuard[TargetType].
  3. Implement the actual runtime check in the function body.
  4. Use the function in an if statement—the type checker narrows the type in the True branch.

Another Example: Checking Dictionary Structure

from typing import TypeGuard, Any

def is_user_dict(val: dict[str, Any]) -> TypeGuard[dict[str, str]]:
    """Check if all values in the dictionary are strings."""
    return all(isinstance(v, str) for v in val.values())

def display_user(data: dict[str, Any]) -> None:
    if is_user_dict(data):
        # Type checker knows all values are str
        for key, value in data.items():
            print(f"{key}: {value.upper()}")
    else:
        print("Dictionary contains non-string values")

Generic Type Guards

You can combine TypeGuard with TypeVar to create reusable, generic type guard functions:

from typing import TypeVar, TypeGuard

T = TypeVar("T")

def is_two_element_tuple(val: tuple[T, ...]) -> TypeGuard[tuple[T, T]]:
    """Check if the tuple has exactly two elements."""
    return len(val) == 2

coords = (10, 20, 30)
if is_two_element_tuple(coords):
    x, y = coords  # Type checker knows this is a 2-tuple

TypeGuard vs TypeIs (Python 3.13+)

Python 3.13 introduced TypeIs (PEP 742) as an improvement over TypeGuard:

FeatureTypeGuardTypeIs
Introduced inPython 3.10 (PEP 647)Python 3.13 (PEP 742)
Narrows in if (True) branchYesYes
Narrows in else (False) branchNot reliablyYes
Recommended for new codeFor complex/unsafe narrowingPreferred for most cases
from typing import TypeIs  # Python 3.13+

def is_str(val: object) -> TypeIs[str]:
    return isinstance(val, str)

def example(val: int | str) -> None:
    if is_str(val):
        print(val.upper())   # val is str
    else:
        print(val + 10)      # val is int (TypeIs narrows both branches)

Best Practice: If you are using Python 3.13+, prefer TypeIs over TypeGuard for most cases. It provides safer, more predictable type narrowing in both branches. Use TypeGuard when you need to narrow to a type that is not a strict subtype of the input.

Common Pitfalls

  1. No Runtime Enforcement: If your TypeGuard function returns True but the data is actually a different type, the type checker will be fooled. Always ensure the logic inside the function accurately reflects the type annotation.
  2. Don’t confuse with the typeguard library: The standard library’s typing.TypeGuard is for static analysis (used by IDEs and type checkers). The third-party typeguard package is a runtime type enforcement tool—they are entirely different.

Chapter 11: Callable Types

When a function accepts another function as an argument (a callback, a strategy, a hook), you need a way to describe the signature of that function parameter. The Callable type from typing serves this purpose.

The syntax is Callable[[ParamType1, ParamType2, ...], ReturnType]:

from typing import Callable

def apply_operation(x: int, y: int, operation: Callable[[int, int], int]) -> int:
    return operation(x, y)

def add(a: int, b: int) -> int:
    return a + b

result = apply_operation(5, 3, add)
print(result)  # Output: 8

In this example, operation is annotated as a callable that takes two int arguments and returns an int. The type checker will ensure that any function passed as operation matches this signature.


Chapter 12: Function Overloading

In Python, you cannot define multiple functions with the same name and different parameter lists (as you can in Java or C++). However, you can use the @overload decorator to declare multiple type signatures for a single function. This helps the type checker understand the relationship between input types and output types.

from typing import overload

@overload
def process(value: int) -> int: ...

@overload
def process(value: str) -> str: ...

def process(value: int | str) -> int | str:
    if isinstance(value, int):
        return value + 10
    elif isinstance(value, str):
        return value.upper()

With these overload declarations, the type checker knows that:

  • If you pass an int, you get an int back.
  • If you pass a str, you get a str back.

Without @overload, the type checker would only know that the function returns int | str, losing the precision of the input-output relationship.


Chapter 13: NoReturn

The NoReturn type is used to annotate functions that never return control to the caller. This typically applies to two scenarios: functions that always raise an exception, and functions that run an infinite loop.

Crashing the Program

from typing import NoReturn

def crash_program() -> NoReturn:
    raise RuntimeError("This function will crash the program")

crash_program()  # Raises RuntimeError

Infinite Loops

from typing import NoReturn

def loop_forever() -> NoReturn:
    while True:
        print("This function will loop forever")

By annotating these functions with NoReturn, the type checker understands that any code after a call to these functions is unreachable and can flag it accordingly.


Chapter 14: Using Pyright for Type Checking

Writing type annotations is only half the story. To actually benefit from them, you need a static type checker that analyzes your code and reports errors. Pyright is a fast, feature-rich type checker for Python developed by Microsoft.

Installing Pyright

pip install pyright

Running Pyright

Once installed, run it against your Python file to check for type errors:

pyright your_script.py

Pyright will analyze your code and report any type mismatches, missing annotations, or other issues it detects.

Note: Other popular type checkers include mypy (the original Python type checker) and pytype (by Google). All of them work with the same annotation syntax.


Quick Reference Table

FeatureImportPurpose
Anyfrom typing import AnyDisable type checking for a variable
Union / |Built-in (3.10+)Accept multiple types
OptionalX | NoneValue or None
Literalfrom typing import LiteralRestrict to specific values
TypedDictfrom typing import TypedDictStructured dictionary with typed keys
Finalfrom typing import FinalImmutable constant
Annotatedfrom typing import AnnotatedAttach metadata to types
TypeVarfrom typing import TypeVarGeneric type placeholder
Callablefrom typing import CallableFunction type with signature
TypeGuardfrom typing import TypeGuardCustom type narrowing (static)
TypeIsfrom typing import TypeIsImproved type narrowing (3.13+)
NoReturnfrom typing import NoReturnFunction never returns
Selffrom typing import SelfReturn type for method chaining
overloadfrom typing import overloadMultiple function signatures