Type Hinting
Standard Python hints are ignored by the computer. They are strictly for humans and code editors to make the code easier to read and maintain.
Basic Syntax
- Variables:
name: type = value - Functions:
def name(param: type) -> return_type:
# Hinting a variable
age: int = 25
# Hinting a function
def greet(name: str) -> str:
return f"Hello, {name}"
# Python allows this! It sees the hints but ignores them at runtime.
print(greet(123)) # Output: "Hello, 123" (No crash!)
Here are the most common labels you will use. Since Python 3.9+, you can use the built-in names (like list and dict) directly for hinting.
- Simple:
int,float,str,bool. - Collections:
list[type],tuple[type],dict[key, val]. - Special:
None,Any,|.
# Simple Variables
price: float = 19.99
is_published: bool = True
# Collections (Be specific!)
# A list that ONLY contains strings
tags: list[str] = ["python", "coding"]
# A dictionary with string keys and integer values
inventory: dict[str, int] = {"apples": 10, "bananas": 5}
# Functions
def greet(name: str) -> str:
return f"Hello, {name}"
When should you actually use hints?
If you do name = "Daniel", adding : str is just extra typing. Python is smart. It uses Type Inference to guess the type based on the value.
Here are some examples of when you might wanna use type hints.
The “Later” Assignment
Sometimes you declare a variable at the top of your code, but you don’t give it a value until later (like after a database call or an if statement). Without a hint, Python and your editor are “blind” to what that variable is supposed to be.
# The editor has no idea what 'user_email' will be
# Therefore, we specify that we expect a string.
user_email: str
if user_found:
user_email = "[email protected]"
else:
user_email = ""
Empty Collections
If you create an empty list like items = [], Python knows it’s a list, but it doesn’t know what you plan to put inside it. Hinting here clarifies your intent.
# "I'm starting empty, but only put numbers in here!"
scores: list[int] = []
# Without the hint, the editor won't warn you if you try:
# scores.append("High Score")
Function Parameters
When you write a function, you have no idea what the user will plug into the parameters. Hinting here is essential because it tells the user (and their code editor) exactly what the function expects.
# WITHOUT hints: Is 'user' a string? An ID number? A User object?
def save_contact(user, phone):
pass
# WITH hints: I know immediately that I need a string and an integer.
def save_contact(user: str, phone: int):
pass
”The Pipe” (|)
In modern Python (3.10+), the pipe symbol | simply means “OR”. It is perfect for situations where data might arrive in more than one format.
- Union (
int | str): Use this when a variable can be one of several types.- Example:
age: int | strallows for the number25OR the string"twenty-five".
- Example:
- Optional (
str | None): Use this when a piece of data is allowed to be missing.- Example:
middle_name: str | None. Not everyone has a middle name. By adding| None, you tell Python: “Expect a string, but don’t crash if the value is just ‘Nothing’.”
- Example:
# A function that accepts an int OR a float and returns an int OR None
def process_score(score: int | float) -> int | None:
if score < 0:
return None
return int(score)
You might see Optional[str] or Union[int, str] in older code. These are exactly the same as the new | syntax. The pipe | is just the modern, cleaner way to write it.
Type Aliases
In Python, if you have a complex type (like a specific tuple or a long list of choices), you can give it a “Nickname” using the type keyword.
You can use it to make your code more readable like it’s much easier to read color: RGB than color: tuple[int, int, int]. If you decide later that a “Color” should actually have 4 numbers (to include transparency), you only have to change it in one place instead of finding every function that uses it.
# We define the "Nickname" once at the top
type RGB = tuple[int, int, int]
# Now we use the nickname instead of the messy tuple
def apply_color(color: RGB):
print(f"Applying color: {color}")
# It even works for complex combinations (Unions)
type UserID = int | str
type UserData = dict[str, str | int | None]
def get_user(id: UserID) -> UserData:
# Logic here...
pass
Evolution of Hinting
If you look at Python code written a few years ago, it will look much “busier” than modern code. Before Python 3.9 and 3.10, Python was much more dependent on the typing module.
# OLD: You had to import EVERYTHING
from typing import List, Dict, Optional, Union
# This was the standard for years:
def process_data(names: List[str], age: Optional[int]) -> Union[str, int]:
pass
As you have seen, Python has become much cleaner. We use built-in lowercase names for collections and the pipe (|) for choices.
Do we still need the typing module?
Yes. While many things moved to built-ins, the typing module is still home to advanced “logic” tools that don’t have built-in equivalents yet. You will still use it for:
Any: When you truly don’t know the type.
Annotated: For adding metadata.
NewType: For creating distinct categories of data.
NewType
- RGB: (255, 0, 0)
- HSL: (0, 100, 50)
To Python, these are both just tuple[int, int, int]. If you accidentally pass an HSL color into a function that expects RGB, Python won’t complain, but your colors will look terrible!
You can use NewType to create a “label” that looks different to the type checker even if the data underneath is the same.
from typing import NewType
# We create two distinct "labels" for the same underlying structure
RGB = NewType("RGB", tuple[int, int, int])
HSL = NewType("HSL", tuple[int, int, int])
def apply_color(color: RGB):
print(f"Applying RGB color: {color}")
apply_color(RGB((255, 0, 0))) # OK
# If you tried to pass an HSL type here, your editor would show an error
# apply_paint(HSL((0, 100, 50)))
Generics
To understand why we need Generics ([T]), we first have to see why Any is dangerous.
When you use Any, you are telling Python: “Turn off your brain.” The problem is that the code editor also turns off its brain. It “forgets” what the data is the moment it enters the function.
from typing import Any
class User:
def __init__(self, name):
self.name = name
def get_first_item(items: list[Any]) -> Any:
return items[0]
users = [User("Alice"), User("Bob")]
first_user = get_first_item(users)
# THE PROBLEM:
# Because the return type is 'Any', your editor has NO IDEA this is a User.
# You won't get autocomplete for 'first_user.name', and you won't get
# warnings if you make a typo.
Instead of saying “I don’t care,” we use [T] as a placeholder. It says: “Whatever type comes in, remember it and use that same type for the output.”
# [T] is a placeholder. "Whatever type 'items' are, I return that same type."
def get_first_item[T](items: list[T]) -> T:
return items[0]
users = [User("Alice"), User("Bob")]
first_user = get_first_item(users)
# Now your editor knows 'first_user' is a User object.
# You get autocomplete for '.name' and full type safety!
The syntax we used above (def func[T]) was introduced in Python 3.12. It is much cleaner, but you will still see the “Old Way” in almost every codebase written before 2024.
from typing import TypeVar
import random
# You had to manually create the TypeVar first
T = TypeVar("T")
# Then you used that variable in your function
def random_choice(items: list[T]) -> T:
return random.choice(items)
Annotated
Standard Type Hinting is limited. You can say age: int, but you can’t say age: int and it must be positive.
Annotated is a way to “attach” extra metadata to a type.
The syntax is Annotated[BaseType, Metadata].
from typing import Annotated
# Python's runtime sees 'int'.
# The string "Must be positive" is just a 'tag' for other tools to read.
age: Annotated[int, "Must be positive"] = 25
While Python ignores the metadata at runtime, it allows some tools to enforce rules (e.g., ‘minimum length’, ‘must be positive’).
The Golden Rule of Typing
“Be generous with what you accept (Inputs), but specific with what you return (Outputs).”
Generous Inputs:
If your function just needs to count items, accept a Sequence (which includes lists, tuples, and strings) rather than just a list.
from collections.abc import Sequence
# BAD: Only accepts a list. If I pass a tuple, I might get a warning/error.
def count_items(items: list[str]):
return len(items)
# GOOD: Accepts lists, tuples, strings, etc.
def count_items(items: Sequence[str]):
return len(items)
count_items(["a", "b"]) # List works
count_items(("a", "b")) # Tuple works too!
We used to do from typing import Sequence, but now for “Abstract” types (like Sequence or Iterable) we get them from collections.abc. Because it describes the behavior of the data, not just the type.
Specific Outputs:
When you return a value, be as specific as possible. If you return Any or a messy Union, the person using your function has to write extra if statements just to figure out what they received.
# BAD: The user has to check: "Did I get a string? An int? Or Nothing?"
def get_data(id: int) -> str | int | None:
pass
# GOOD: The user knows exactly what they are getting.
def get_user_name(id: int) -> str:
pass
External Type Stubs
Sometimes you use a library (like requests) that doesn’t have type hints built-in. Your editor will be “blind” to what the library returns.
The Fix: You can install “Type Stubs” (files ending in .pyi that contain only the labels).
- Command:
uv add types-requests
Once installed, your editor will suddenly “see” and understand the library’s types.
Dataclasses
In Python, we have two types of classes:
- Behavior Classes: Focus on actions (e.g., a
PaymentProcessoror aDatabaseConnector). - Data Classes: Focus on storage (e.g., a
User, aProduct, or aCoordinate).
For classes that just “hold stuff,” writing the __init__, __repr__ (printing logic), and __eq__ (comparison logic) manually is repetitive. Dataclasses automate all of that for you.
from dataclasses import dataclass
# WITHOUT Dataclass: You have to write all this boilerplate
class ManualUser:
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def __repr__(self):
return f"User(name={self.name}, age={self.age})"
# WITH Dataclass: One decorator does it all
@dataclass
class User:
name: str
age: int
user1 = User("Alice", 30)
user2 = User("Alice", 30)
print(user1) # Works! (Auto-generated __repr__)
print(user1 == user2) # True! (Auto-generated __eq__ comparison)
Manual Validation (Old Way)
Before modern libraries, we had to write “Guard Clauses” to manually check every single variable. It made functions messy and hard to maintain.
def create_user(name, age):
if not isinstance(name, str):
raise TypeError("Name must be a string")
if not isinstance(age, int):
raise TypeError("Age must be an integer")
# ... finally do the logic ...
Pydantic validate_call (Modern Way)
Pydantic is a library that turns your hints into rules. By adding one decorator (@validate_call), you turn Python’s “suggestions” into a strict bouncer.
from pydantic import validate_call
@validate_call
def create_user(first_name: str, last_name: str, age: int):
print(f"User {first_name} created. Age: {age}")
# This works perfectly
create_user("Daniel", "Adrian", 28)
# DATA COERCION: Pydantic is smart.
# It sees "28" is a string, but it knows it can turn it into an int.
create_user("Daniel", "Adrian", "28") # This still works!
# VALIDATION ERROR: This will CRASH.
# Pydantic cannot turn "thirty-eight" into a number.
create_user("Daniel", "Adrian", "thirty-eight")
# Result: pydantic_core._pydantic_core.ValidationError
How Pydantic uses Annotated
If @validate_call is the Bouncer at the door, then Annotated with Pydantic’s Field is the Dress Code Details. It tells the bouncer exactly what the requirements are for that specific guest.
What is Field()?
Field is Pydantic’s way of writing rules like min length, max value, etc.
from typing import Annotated
from pydantic import Field, validate_call
# We create a reusable "Type" that is an integer
# PLUS a metadata 'Field' that says it must be between 21 and 120.
# gt = Greater Than
# le = Less than or Equal to
ValidAge = Annotated[int, Field(gt=21, le=120)]
@validate_call
def register_user(age: ValidAge):
print(f"Age {age} is valid!")
# This works (within 0-120)
register_user(25)
# This CRASHES (violates the 'gt=21' rule)
# register_user(16)
# This CRASHES (violates the 'le=120' rule)
# register_user(150)