Alright, let’s get our hands dirty with the actual syntax. This is where we stop waving our hands around talking about “the benefits of type hints” and start writing code that actually tells the reader—and more importantly, your future self—what’s supposed to be going on.

The core idea is laughably simple: you’re just attaching a label to a piece of your code. Think of it like putting a “FRAGILE - GLASS” sticker on a moving box. It doesn’t change what’s inside the box, but it tells everyone who handles it what to expect and how to behave. Python’s type hints work the same way; they’re metadata. At runtime, they’re mostly ignored. Their power is unleashed by your IDE and static type checkers before the code runs.

Annotating Variables

You annotate a variable by placing a colon (:) after its name and stating its type. The most important rule, which people bungle constantly, is that you do this when you first define the variable. You’re declaring your intent right from the start.

name: str = "Guido"
answer: int = 42
pi: float = 3.14159
is_awesome: bool = True  # Obviously.

# You can even annotate a variable without immediately assigning a value.
# This tells the type checker: "Hey, I'm declaring this now, and I promise it will be this type."
unassigned_yet: list[str]
# ... some lines of code later ...
unassuted_yet = ["this", "is", "a", "list", "of", "strings"]

What happens if you lie? The type checker will politely (or not so politely, depending on your configuration) call you out.

declared_as_int: int = "I am a string"  # Your IDE will light up like a Christmas tree.
# Mypy will say: error: Incompatible types in assignment (expression has type "str", variable has type "int")

Annotating Function Parameters and Return Values

This is where type hints pay their rent. Function signatures become a clear contract: “Give me X and Y, and I’ll give you back Z.”

You annotate parameters just like variables. The return type is declared using an arrow (->) at the end of the function signature.

def greet(name: str, loud: bool = False) -> str:
    greeting = f"Hello, {name}!"
    return greeting.upper() if loud else greeting

# This is now crystal clear.
result = greet("Alice", loud=True)  # result is known to be a str.

Here’s the beautiful part: if you try to call greet(123), the type checker will immediately flag it: “Hey, you promised name would be a string, and 123 is very much not a string.” You catch the bug before you even run the code. It’s like having a hyper-vigilant code reviewer who never sleeps.

The None Return Type

A function that doesn’t explicitly return a value actually returns None in Python. This is a type like any other, and you should annotate it. It’s crucial for being explicit about side-effect-only functions.

def display_greeting(name: str) -> None:
    """Print a greeting. Doesn't return a value; it just does something."""
    print(f"Hi, {name}!")

# This is correct. A good type checker will warn you if you try to use the result of this function.
display_greeting("Bob")

Forgetting the -> None is a common rookie mistake. Without it, the type checker might assume you just forgot to declare a return type and remain silent, or worse, infer a type you didn’t intend.

Why Simple Annotations Are a Game Changer

You might think this is trivial, and on the surface, it is. But the shift in workflow is profound. You’re no longer just writing code; you’re writing code and its specification simultaneously. Your IDE’s autocomplete becomes scarily accurate. Refactoring becomes less of a terrifying leap of faith. Code that you wrote six months ago suddenly explains itself to you. It’s the single highest ROI change you can make to your Python coding practice. And we’re just getting started—the typing module is where the real fun begins.