Several examples are presented to clarify the definition and application of type annotations in Python.

__future__ syntax

__future__ is a special Python module that lets us opt into language features early—before they become the default behavior. We enable a feature per file with:

1
from __future__ import feature_name

Quick facts:

  • Purpose: forward-compatibility; smooth migration to new syntax/semantics.
  • Scope: only the current module (file).
  • Placement: must be the first non-comment line.
  • Validation: only known feature names are allowed.

Common modern example:

1
2
3
from __future__ import annotations
# Stores type annotations as strings (postponed evaluation).
# Useful for forward references and avoiding import cycles.

Note: In Python 3.11+, annotations behavior is already the default, so that import is optional.

type annotations

We will give several examples to illustrate it.

from __future__...

from __future__ import annotations

What it does: postpones evaluation of type annotations. Instead of evaluating them immediately, Python stores them as strings.
Why that’s useful:

  • Forward references without quotes:

    1
    2
    3
    4
    5
    from __future__ import annotations

    class Node:
    def __init__(self, next: Node | None = None) -> None: # no quotes needed
    self.next = next

    (Without the future import on Python <3.11 we’d need 'Node'.)

Notes

the | means “or” (a union type).

A | B = value can be type A or type B. It’s the modern (Python 3.10+) shorthand for typing.Union[A, B].

Examples:

1
2
def parse(x: int | str) -> int:  # x can be int OR str
return int(x)

What Node | None = None means?

Inside a class like a linked-list node:

1
2
3
4
5
from __future__ import annotations  # not needed on Python 3.11+

class Node:
def __init__(self, next: Node | None = None) -> None:
self.next = next
  • Node | None → the parameter next may be either a Node or None (i.e., it’s optional / nullable).
  • = None → if we don’t pass a value, the default is None.
  • So the constructor allows Node(), or Node(next=another_node).

Outside of type hints, | is the bitwise OR operator (for ints) and also used for set/dict union. In annotations it’s specially defined to build union types.

  • Fewer import cycles: we can annotate with types defined later or in modules that would otherwise create circular imports.
  • Faster startup: heavy typing objects aren’t constructed at import time. If we do need concrete types at runtime, use typing.get_type_hints() which evaluates them on demand.

Version note: In Python 3.11+, postponed evaluation is the default; the import is harmless but unnecessary. In 3.7–3.10, it’s very helpful.

Location: Must be the first non-comment line in the file.

def __init__...

def __init__(self, arg1: str, arg2: int) -> None:

  • __init__ is the initializer. It sets up instance state.
  • -> None is a return type hint saying “this function returns nothing.” For __init__, that’s required: returning a value is an error.
1
2
3
4
class Name:
def __init__(self, arg1: str, arg2: int) -> None:
self.attr1 = arg1
self.attr2 = arg2

Mistakes

The syntax for i: int in ... is not allowed. This line:

1
2
for i: int, x: int in enumerate(nums):
...

is a SyntaxError in current Python versions. Python only allows annotations in:

  • function parameters / return types (def f(x: int) -> str: ...)

  • variable annotations / assignments (index_of: dict[int, int] = {} or x: int)

but not in the target of a for loop. If we try it in a real interpreter, it fails. How to annotate those variables. We can annotate i and x, just not inside the for header:

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

i: int
x: int
for i, x in enumerate(nums):
index_of[i] = x

This is legal Python: i: int and x: int are normal variable annotations.

But in practice, we almost never bother, because Type checkers usually infer loop variable types

If we annotate our function signature and main variables, the loop variables are obvious:

1
2
3
4
5
def build_index(nums: list[int]) -> dict[int, int]:
index_of: dict[int, int] = {}
for i, x in enumerate(nums):
index_of[i] = x
return index_of

Given nums: list[int], a type checker will infer:

  • i is int (the index from enumerate)

  • x is int (the element type of nums)

So extra annotations on i and x would just add noise.

Practical rule of thumb

  • Always annotate:

    • function parameters and return types

    • important attributes / module-level variables

    • tricky locals when the type isn’t obvious

  • Rarely annotate:

    • simple loop variables (i, x, item in for item in items)

method

def method(self) -> str:

  • A method is just a function defined inside a class; it receives the instance as the first parameter (by convention, self).
  • -> str means the method returns a string.
1
2
3
4
5
6
7
8
class Name:
def __init__(self, arg1: str, arg2: int) -> None:
self.attr1 = arg1
self.attr2 = arg2

def method(self) -> str:
"""Return the primary attribute as a string."""
return self.attr1

if __name__ == "__main__": main()

This is the script entry-point guard.

  • When we run the file directly: python app.py Python sets __name__ == "__main__"main() executes.
  • When we import the file from somewhere else: import app Python sets __name__ == "app" → the guarded block does not run. This prevents side-effects (like starting a program) on import.

Example:

1
2
3
4
5
6
7
8
# app.py
def main() -> None:
print("Running as a script")

if __name__ == "__main__":
main()
# other.py
import app # does NOT print anything, because __name__ != "__main__"

This pattern keeps modules safe to import while still runnable as scripts.

Notes

a) What is __name__?

  • Python sets a special variable __name__ in every module (file).
  • If we run a file directly (python app.py), Python sets __name__ to the string "__main__".
  • If we import that file (import app), Python sets __name__ to the module’s name, e.g. "app" (or "package.app" if inside a package).

b) What does if __name__ == "__main__": do?

It’s a guard that says: only run this block when the file is executed as a script, not when it’s imported.

1
2
3
4
5
6
# app.py
def main() -> None:
print("Running as a script")

if __name__ == "__main__":
main()
  • Run python app.py__name__ == "__main__" → prints “Running as a script”.
  • From other.py do import app__name__ == "app" → the if condition is False, so main() is not called, and nothing prints. That’s by design, not an error.

This pattern prevents unwanted side effects when a module is imported.

c) Does __name__ need to match the class name (e.g., Name)?

No.

  • __name__ refers to the module name (the filename, like app.py"app").
  • Name is a class name inside the module. They are unrelated.

The code:

1
2
3
4
5
6
7
8
from __future__ import annotations

class Name:
"""Simple class holding two attributes."""
...

if __name__ == "__main__":
main()

Here, __name__ is compared to "__main__" based on how the file is run, not the class name. We can have many classes/functions in the file; the guard is only about whether to auto-run main().

d) Tiny demo to see it

app.py

1
2
3
4
5
6
7
def main() -> None:
print("Running as a script")

print(f"In app.py, __name__ is: {__name__}")

if __name__ == "__main__":
main()
  • Direct run:

    1
    2
    3
    $ python app.py
    In app.py, __name__ is: __main__
    Running as a script
  • Imported by another file:

other.py

1
2
import app
print("Imported app; not running its main().")

Run:

1
2
3
$ python other.py
In app.py, __name__ is: app
Imported app; not running its main().

Notice: when imported, __name__ becomes "app", so the guarded main() doesn’t execute.

Bottom line:

  • __name__ is the module’s name, not a class name.
  • The if __name__ == "__main__": guard runs code only when the file is executed directly, keeping imports clean and side-effect-free.