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.
name = "curtis"
age = 30
height = 175.5
is_admin = TrueThe 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:
i: int = 42
f: float = 3.14
s: str = "hello"
b: bool = True
n: None = NoneThe : 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.
x: int = "this is a string"
print(x) # this is a stringReasons to write them anyway:
- Editor autocomplete and go-to-definition become accurate — VS Code and PyCharm both read types
- Type checkers (mypy / pyright / Pyrefly) catch problems at compile time
- Acts as documentation when others read your code — function signatures alone tell what is taken and returned
- 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 #
count: int = 0
name: str
name = "curtis" # declare type first, assign value latername: str is also valid — type only, no value. Common in class field declarations (you’ll meet this properly in Chapter 8 dataclass).
Functions #
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.
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.
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.
def find_user(id: int) -> str | None:
if id == 1:
return "curtis"
return Nonestr | None is the union shorthand supported from Python 3.10. Before that you had to import Optional[str] or Union[str, None] from typing.
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.
def parse(value: str) -> int | float | None:
try:
return int(value)
except ValueError:
try:
return float(value)
except ValueError:
return NoneNumbers — int and float #
Python’s int is arbitrary precision. No 64-bit limit.
big = 10 ** 100
print(big)
# 10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000float is IEEE 754 double. The same floating-point gotchas as other languages apply.
print(0.1 + 0.2) # 0.30000000000000004
print(0.1 + 0.2 == 0.3) # FalseFor finance or exact decimals, use decimal.Decimal.
from decimal import Decimal
print(Decimal("0.1") + Decimal("0.2")) # 0.3A small tip — numeric literals #
You can insert underscores for readability (3.6+).
ONE_MILLION = 1_000_000
HEX = 0xFF_FF
BIN = 0b_1010_1010Strings — str and f-strings
#
There’s no difference between single and double quotes. Use one consistently per project (usually ").
s1 = "hello"
s2 = 'world'
s3 = """multi-line
string"""For interpolation, use f-strings.
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.
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.
print(True + True) # 2
print(isinstance(True, int)) # TrueInteresting, 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.
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.
n = 42
s = "answer: " + str(n) # must convert with str(n)
# "answer: " + n → TypeError
age = int(input("age: ")) # input returns str → convert to intCommon 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 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:
UserId = int # just a variable — works as an alias
# or
from typing import TypeAlias
UserId: TypeAlias = intNew 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.
uv add --dev pyright
uv run pyright .A small example to see it in action:
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.
- Create a new project
typing-playand addpyrightas a dev dependency. Writedef add(a: int, b: int) -> int: return a + binplay.py, deliberately calladd("hello", 1), and confirm thatuv run pyright play.pycatches the error. - In
play.py, definedef first(items: list[int]) -> int | None: return items[0] if items else None, and confirm that bothfirst([1, 2, 3])andfirst([])work. Since the return type isint | None, writing something likeresult + 1on the call side has pyright point out theNonepossibility. See that error yourself too. - Define
type UserId = intandtype UserMap = dict[UserId, str]with thetypekeyword, and write a function that takesUserMapas an argument. Confirm whether pyright treatsUserIdandintas mutually compatible (thetypesyntax 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 asT | None, alias astype 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.