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?
- 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.
- 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.
- 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.
- 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
| Type | Description |
|---|---|
int | Represents an integer (e.g., 42, -7) |
float | Represents a floating-point number (e.g., 3.14) |
str | Represents a string (e.g., "hello") |
bool | Represents a boolean value (True or False) |
None | Represents the absence of a value |
Any | Represents 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
| Behavior | object | Any |
|---|---|---|
| Calls allowed without type check | No | Yes |
| Safety | High | None |
| Type checker protection | Strong | Disabled |
| Runtime crash possible | Yes (but caught early by typing) | Yes (but not warned) |
Best Practice: Prefer
objectoverAnywhenever possible. UseAnyonly 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). Inlist,dict, andset, 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 | Noneis equivalent to the olderOptional[X]fromtyping. 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
typestatement syntax was introduced in Python 3.12. For earlier versions, useTypeAliasfrom thetypingmodule: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:
Annotatedis 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
| Technique | Example | Narrows to |
|---|---|---|
isinstance() | if isinstance(x, int): | int |
is not None | if x is not None: | Removes None |
assert | assert isinstance(x, str) | str |
callable() | if callable(x): | Callable type |
| Truthiness check | if 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:
TypeGuardis 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
- Define a function that accepts the value you want to narrow.
- Annotate the return type as
TypeGuard[TargetType]. - Implement the actual runtime check in the function body.
- Use the function in an
ifstatement—the type checker narrows the type in theTruebranch.
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:
| Feature | TypeGuard | TypeIs |
|---|---|---|
| Introduced in | Python 3.10 (PEP 647) | Python 3.13 (PEP 742) |
Narrows in if (True) branch | Yes | Yes |
Narrows in else (False) branch | Not reliably | Yes |
| Recommended for new code | For complex/unsafe narrowing | Preferred 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
TypeIsoverTypeGuardfor most cases. It provides safer, more predictable type narrowing in both branches. UseTypeGuardwhen you need to narrow to a type that is not a strict subtype of the input.
Common Pitfalls
- No Runtime Enforcement: If your
TypeGuardfunction returnsTruebut 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. - Don’t confuse with the
typeguardlibrary: The standard library’styping.TypeGuardis for static analysis (used by IDEs and type checkers). The third-partytypeguardpackage 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 anintback. - If you pass a
str, you get astrback.
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
| Feature | Import | Purpose |
|---|---|---|
Any | from typing import Any | Disable type checking for a variable |
Union / | | Built-in (3.10+) | Accept multiple types |
Optional | X | None | Value or None |
Literal | from typing import Literal | Restrict to specific values |
TypedDict | from typing import TypedDict | Structured dictionary with typed keys |
Final | from typing import Final | Immutable constant |
Annotated | from typing import Annotated | Attach metadata to types |
TypeVar | from typing import TypeVar | Generic type placeholder |
Callable | from typing import Callable | Function type with signature |
TypeGuard | from typing import TypeGuard | Custom type narrowing (static) |
TypeIs | from typing import TypeIs | Improved type narrowing (3.13+) |
NoReturn | from typing import NoReturn | Function never returns |
Self | from typing import Self | Return type for method chaining |
overload | from typing import overload | Multiple function signatures |