Contents
2 Chapter

Variables, basic types, and type hints

Python is a dynamic language, but modern Python writes types from the start. int/str/bool/None and built-in generics, the int | None shortcut, and mypy/pyright.

We continue directly from the project created in Chapter 1 Getting started and uv setup. The topic of this chapter is variables, basic types, and type hints.

The preface says that “type hints first” is one of the book’s core principles. This chapter is where you first see how that principle shows up in actual code. Advanced typing tools like the Protocol of Chapter 9 or the ParamSpec of Chapter 20 all sit on top of the fundamentals of this chapter, so reading this chapter carefully makes what comes later much lighter.

Python is a dynamically typed language. You don’t have to write types on variables. Yet modern Python writes types from the start — that’s the standard now. This chapter explains why and how.

Variables — no separate declaration #

The biggest difference from many other languages: there’s no let, var, or int x keyword. Just assign a value to a name and that’s a variable.

Variables
name = "curtis"
age = 30
height = 175.5
is_admin = True

The naming convention is snake_case. It’s different from JavaScript’s camelCase, which feels strange at first. Constants use uppercase + underscore (MAX_RETRY = 3).

Basic types — 4 + 1 #

Here are the five most-used types:

Basic types
i: int = 42
f: float = 3.14
s: str = "hello"
b: bool = True
n: None = None

The : int part is a type hint. After the variable name, write a colon and a type. It doesn’t affect behavior, but tools (IDEs / type checkers) read it for validation and autocomplete.

Why annotate types in a dynamically typed language? #

Type hints are simply ignored at runtime. The following runs fine.

Ignored at runtime
x: int = "this is a string"
print(x)   # this is a string

Reasons to write them anyway:

  1. Editor autocomplete and go-to-definition become accurate — VS Code and PyCharm both read types
  2. Type checkers (mypy / pyright / Pyrefly) catch problems at compile time
  3. Acts as documentation when others read your code — function signatures alone tell what is taken and returned
  4. Refactor safety net — change a function signature and you see what breaks

Old Python code lacks types. The modern Python principle is to add them whenever possible in new code, and this book follows that principle.

Type hints — variables, functions, collections #

Variables #

Variable annotations
count: int = 0
name: str
name = "curtis"     # declare type first, assign value later

name: str is also valid — type only, no value. Common in class field declarations (you’ll meet this properly in Chapter 8 dataclass).

Functions #

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

def greet(name: str) -> None:
    print(f"hi, {name}")

After parameters: : type; for return: -> type. If nothing returns, -> None. Functions without these leave the IDE in a “don’t know what it returns” state, dimming autocomplete in callers as well.

Collections — built-in generics #

For collections like lists and dicts, annotate element types too.

Collection types
nums: list[int] = [1, 2, 3]
names: list[str] = ["a", "b"]
ages: dict[str, int] = {"curtis": 30, "smith": 25}
unique: set[str] = {"a", "b"}
point: tuple[float, float] = (1.0, 2.0)

The syntax of putting [] directly on built-in types like list[int] is supported from Python 3.9. Before that, it was from typing import List then List[int]. You’ll still see it in old code, but new code unifies on the built-in form.

Old style (3.8 and below) — don't use in new code
from typing import List, Dict
nums: List[int] = [1, 2, 3]
ages: Dict[str, int] = {"curtis": 30}

Appendix A’s Moving old Python code to the modern style revisits how this conversion can be automated with tooling.

None and option types — int | None #

For fields where a value may or may not exist.

Option type — modern syntax
def find_user(id: int) -> str | None:
    if id == 1:
        return "curtis"
    return None

str | None is the union shorthand supported from Python 3.10. Before that you had to import Optional[str] or Union[str, None] from typing.

Old style — don't use in new code
from typing import Optional, Union

def find_user(id: int) -> Optional[str]: ...
def parse(value: str) -> Union[int, float, None]: ...

New code always uses |. It is shorter and needs no extra import.

Combining multiple types
def parse(value: str) -> int | float | None:
    try:
        return int(value)
    except ValueError:
        try:
            return float(value)
        except ValueError:
            return None

Numbers — int and float #

Python’s int is arbitrary precision. No 64-bit limit.

Big integer
big = 10 ** 100
print(big)
# 10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

float is IEEE 754 double. The same floating-point gotchas as other languages apply.

Floating point
print(0.1 + 0.2)       # 0.30000000000000004
print(0.1 + 0.2 == 0.3)  # False

For finance or exact decimals, use decimal.Decimal.

Exact decimals
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2"))  # 0.3

A small tip — numeric literals #

You can insert underscores for readability (3.6+).

Readability
ONE_MILLION = 1_000_000
HEX = 0xFF_FF
BIN = 0b_1010_1010

Strings — str and f-strings #

There’s no difference between single and double quotes. Use one consistently per project (usually ").

String basics
s1 = "hello"
s2 = 'world'
s3 = """multi-line
string"""

For interpolation, use f-strings.

f-string
name = "curtis"
age = 30

print(f"hi, {name}! you are {age} years old.")
print(f"next year you'll be {age + 1}.")
print(f"{name = }")    # name = 'curtis'  (handy for debugging)

The f"{var = }" form prints the variable name and the value together. Used a lot for debug prints.

t-strings — Python 3.14’s new syntax #

PEP 750 added t-strings in Python 3.14. They look like f-strings, but interpolation does not happen immediately; the result is a Template object instead.

t-string (3.14+)
from string.templatelib import Template

name = "curtis"
tpl: Template = t"hi, {name}!"
# Template object. Not yet joined into a string.

# Pass to a function that handles it safely
def render_html(template: Template) -> str: ...
html = render_html(t"<a href='{user_url}'>{user_name}</a>")

When to use? Places where user input must be handled safely — SQL, HTML, shell. With f-strings, the interpolated result is a string immediately, so it’s easy to forget to escape; with t-strings, the library carries the escape responsibility. At the entry level, “knowing f-strings is enough”; just recognize t-strings when you encounter them in library code.

bool and truthiness #

There are two values, True and False, and bool is a subtype of int.

bool is int
print(True + True)     # 2
print(isinstance(True, int))  # True

Interesting, but mixing int where bool is expected isn’t recommended. Use True / False explicitly.

Truthy / falsy #

Empty containers and 0 are false; everything else is true.

falsy values
if not []:    print("empty list is falsy")
if not "":    print("empty str is falsy")
if not 0:     print("zero is falsy")
if not None:  print("None is falsy")

Expressions like if not items: are common. Shorter than JavaScript’s if (!arr.length).

Type conversion — explicit #

Python performs almost no implicit type coercion. Adding an int and a string raises an error.

Explicit conversion required
n = 42
s = "answer: " + str(n)   # must convert with str(n)
# "answer: " + n  → TypeError

age = int(input("age: "))  # input returns str → convert to int

Common conversions:

Common conversions
str(42)        # '42'
int("42")      # 42
int("42", 16)  # 66 (interpret as hex)
float("3.14")  # 3.14
bool(0)        # False
bool("any")    # True (only the empty string is False)
list("abc")    # ['a', 'b', 'c']

Type aliases — the type keyword #

When the same type shape repeats, name it.

Type alias (3.12+)
type UserId = int
type UserName = str
type UserMap = dict[UserId, UserName]

def get_user(id: UserId) -> UserName | None: ...

The type syntax was added in Python 3.12. Before that, it was written as:

Old style
UserId = int    # just a variable — works as an alias
# or
from typing import TypeAlias
UserId: TypeAlias = int

New code unifies on type.

Type checkers — mypy / pyright #

Since type hints aren’t validated at runtime, a separate tool does static checking. Two camps:

  • mypy — the oldest standard. Python-camp tool. uv add --dev mypy
  • pyright / Pylance — Microsoft’s. Fast, with strong VS Code integration. uv add --dev pyright
  • Pyrefly — Astral’s newest (still beta as of 2026). Faster next-gen contender

For a new project, starting with pyright is reasonable. With the Python extension in VS Code, Pylance kicks in automatically and you barely need any configuration.

Add pyright
uv add --dev pyright
uv run pyright .

A small example to see it in action:

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

result = add("hello", 1)   # ✗ str is not int
$ uv run pyright check.py
check.py:4:18 - error: Argument of type "Literal['hello']" cannot be assigned to parameter "a" of type "int"

Caught before runtime. It can feel like nagging at first, but after a month you’ll wonder how you lived without it.

In this chapter we only invoke pyright as a one-line command. In Chapter 30 Type-checker setup and CI integration, we set up the formal operational configuration — pyproject.toml settings, the strict option, pre-commit, and GitHub Actions.

Exercises #

The core of this chapter is “write types and the tool catches things”. See the tool catch things yourself.

  1. Create a new project typing-play and add pyright as a dev dependency. Write def add(a: int, b: int) -> int: return a + b in play.py, deliberately call add("hello", 1), and confirm that uv run pyright play.py catches the error.
  2. In play.py, define def first(items: list[int]) -> int | None: return items[0] if items else None, and confirm that both first([1, 2, 3]) and first([]) work. Since the return type is int | None, writing something like result + 1 on the call side has pyright point out the None possibility. See that error yourself too.
  3. Define type UserId = int and type UserMap = dict[UserId, str] with the type keyword, and write a function that takes UserMap as an argument. Confirm whether pyright treats UserId and int as mutually compatible (the type syntax of 3.12+ is a simple alias).

In one line: Modern Python writes types on variables, functions, collections, and options alike. Built-in generic list[int], option as T | None, alias as type Name = ..., tool as pyright. Runtime behavior is unaffected, but IDE, type checker, and refactoring all change.

Next chapter #

In the next chapter, Chapter 3 Control flow — if, while, for, match-case, we cover flow control — if, while, for, and the match-case added in 3.10. The key is how it differs from switch in other languages. The type hints set up in this chapter come back into play in the match-case pattern branches of Chapter 3.

X