Modern Typing
This is the spine of the whole guide. Python’s type system is the thing that makes the rest of modern Python feel like home to you: FastAPI reads your type hints to generate routes and OpenAPI, Pydantic reads them to validate data, and your editor reads them to autocomplete and catch bugs before you run anything. Get fluent here and everything downstream is easier.
The good news: if you think in TypeScript or Go types, you already think in Python types. The mental model transfers almost one-to-one. What you need to learn is the modern syntax (a lot of what’s on the internet is the old way) and a handful of Python-specific gotchas.
The one thing to internalize: hints are erased
Section titled “The one thing to internalize: hints are erased”Python type hints are annotations only. They are not enforced at runtime.
// TS types are erased at compile time. tsc checks them, then emits plain JS.function greet(name: string): string { return `Hello, ${name}`;}// At runtime, `name` could be anything — JS doesn't check.greet(42 as any); // compiles fine if you fight the checker; runs fine too// Go types are real at compile AND runtime. The compiler refuses to build// if types don't line up. There is no "ignore the error and run anyway".func Greet(name string) string { return "Hello, " + name}// Greet(42) does not compile. Period.def greet(name: str) -> str: return f"Hello, {name}"
# The hint `str` is metadata. Python NEVER checks it at runtime.greet(42) # runs without error; produces "Hello, 42"# A separate tool (ty / mypy) flags this BEFORE you run. The interpreter won't.So Python is like TypeScript, not like Go: a separate checker validates the
types statically, then the runtime ignores them. The difference from TS is that
there’s no compile step at all — python main.py runs erased-but-unchecked code
directly. You run ty (or mypy) as a separate gate, usually in your editor
and CI.
| TypeScript | Go | Python | |
|---|---|---|---|
| Types checked | at build (tsc) | at build (compiler) | by a separate tool (ty/mypy) |
| Types at runtime | erased | enforced | erased (just __annotations__ metadata) |
| Can you run code that fails the checker? | yes (emit-on-error) | no | yes — the interpreter never looks |
| Gradual / opt-in typing | yes | no | yes |
Why bother, then? Because everything reads them
Section titled “Why bother, then? Because everything reads them”In 2026, untyped Python is legacy Python. Type hints are how the modern ecosystem works:
- ty / mypy catch bugs statically — your CI gate, like
tsc --noEmit. - Editors (Pyright/Pylance, PyCharm) give autocomplete, go-to-def, and inline errors entirely from hints.
- FastAPI turns
user_id: intin a route signature into request parsing, validation, and OpenAPI docs. - Pydantic builds validators from your field types.
- dataclasses use them to generate
__init__.
You’ll write hints on every function signature and every dataclass field. Inside
function bodies, let inference do the work (like Go’s := or TS inference) —
annotate locals only when the checker can’t figure it out.
Basic hints and modern built-in generics
Section titled “Basic hints and modern built-in generics”Annotate with name: Type. Return types go after ->.
age: int = 30name: str = "Alice"ratio: float = 19.99 # Python has ONE float (64-bit) and ONE int (arbitrary precision)active: bool = True
def add(a: int, b: int) -> int: return a + b
def log(message: str) -> None: # `None` is the return type for "returns nothing", like void/Unit print(message)Here’s the type mapping you’ll reach for constantly:
| TypeScript | Go | Python | Notes |
|---|---|---|---|
number | int / int64 | int | Python ints are arbitrary precision — no overflow |
number | float64 | float | always 64-bit |
boolean | bool | bool | |
string | string | str | |
T[] / Array<T> | []T | list[T] | mutable, ordered |
[A, B] (tuple) | — | tuple[A, B] | fixed-size; tuple[int, ...] = variadic |
Record<string, V> | map[string]V | dict[str, V] | |
Set<T> | map[T]struct{} | set[T] | |
T | null | *T | T | None | |
A | B | interface / any | A | B | union |
unknown | any | object | safe top type |
any | any / interface{} | Any | escape hatch (opts out of checking) |
void | — | None | |
never | — | Never | bottom type |
Use built-in generics — stop importing from typing
Section titled “Use built-in generics — stop importing from typing”This is the single biggest “your Python is dated” tell. Old Python (pre-3.9)
made you import List, Dict, Optional, Union from typing. Modern
Python uses the builtins and the | operator directly. Name the old way once,
then never write it again:
# THE OLD WAY (pre-3.10) — recognize it, then never write it:from typing import List, Dict, Optional, Union, Tuple, Setdef old(xs: List[int], m: Dict[str, int], x: Optional[str]) -> Union[int, str]: ...
# THE MODERN WAY (3.10+, what you write in 2026):def new(xs: list[int], m: dict[str, int], x: str | None) -> int | str: ...The full modern vocabulary:
xs: list[str] # was List[str]m: dict[str, int] # was Dict[str, int]pair: tuple[int, str] # fixed 2-tuple, was Tuple[int, str]nums: tuple[int, ...] # variadic tuple ("a tuple of ints, any length")tags: set[str] # was Set[str]maybe: str | None # was Optional[str]either: int | str # was Union[int, str]mixed: list[dict[str, int | None]] # nests exactly like you'd expectX | None is the idiom you’ll use most. It’s literally Optional[X] under the
hood, but str | None reads better and matches TS’s string | null.
function findUser(id: number): User | null { return users.get(id) ?? null;}const tags: Set<string> = new Set();const scores: Record<string, number> = {};func findUser(id int64) *User { if u, ok := users[id]; ok { return &u } return nil}tags := map[string]struct{}{}scores := map[string]int{}def find_user(id: int) -> User | None: return users.get(id) # dict.get returns None if absent — already `User | None`
tags: set[str] = set()scores: dict[str, int] = {}Special forms: Literal, Final, Annotated, Self, Never
Section titled “Special forms: Literal, Final, Annotated, Self, Never”These cover the cases plain types can’t express. Most map cleanly to TS.
Literal — exact values as types
Section titled “Literal — exact values as types”Like TS string-literal unions. The value itself is the type.
from typing import Literal
Method = Literal["GET", "POST", "PUT", "DELETE"]
def request(method: Method, url: str) -> None: ...
request("GET", "/users") # okrequest("PATCH", "/users") # ty error: "PATCH" is not a valid Method// TS equivalent — identical ideatype Method = "GET" | "POST" | "PUT" | "DELETE";Go has no equivalent — you’d use a named type plus runtime validation, or a set
of const values with no compiler guarantee that a function only receives them.
Final — can’t be reassigned
Section titled “Final — can’t be reassigned”Like TS const / readonly (the checker enforces it; the runtime doesn’t).
from typing import Final
MAX_RETRIES: Final = 3 # type inferred as int, reassignment is a checker errorAPI_BASE: Final[str] = "https://api.example.com"
MAX_RETRIES = 5 # ty error: cannot assign to FinalAnnotated — attach metadata to a type
Section titled “Annotated — attach metadata to a type”Annotated[T, ...] is T plus extra payload that tools read. You won’t use it
much by hand, but FastAPI and Pydantic lean on it heavily (you’ll see it
everywhere in module 04 and 07), so recognize it now:
from typing import Annotated
# Reads as `int`, but carries metadata a library can act on.UserId = Annotated[int, "primary key"]
# In FastAPI this is how dependencies and validation constraints are expressed:# def handler(q: Annotated[str, Query(max_length=50)]): ...Self — methods that return their own type (PEP 673)
Section titled “Self — methods that return their own type (PEP 673)”For fluent/builder APIs and from_* classmethods. No more naming the class or
using a TypeVar bound to it.
from typing import Self
class QueryBuilder: def where(self, cond: str) -> Self: # returns the actual subclass type ... return self
@classmethod def new(cls) -> Self: return cls()// TS: `this` type does the same jobclass QueryBuilder { where(cond: string): this { /* ... */ return this; }}Never + assert_never — exhaustiveness checking
Section titled “Never + assert_never — exhaustiveness checking”Never is the bottom type (TS never, Kotlin Nothing). Its killer use is
making the checker prove you handled every case of a union — the same trick as
TS’s exhaustive switch:
from typing import Literal, assert_never
Shape = Literal["circle", "square"]
def area(shape: Shape, size: float) -> float: match shape: case "circle": return 3.14159 * size * size case "square": return size * size case _: assert_never(shape) # if you add "triangle" to Shape, ty errors HEREIf you extend Shape with a new variant and forget to handle it, ty reports
that shape reaching assert_never isn’t Never — a compile-time-style
guarantee that you covered the union. This is the Python equivalent of the TS
const _exhaustive: never = x pattern and Go’s “default panics” convention, but
checked statically.
PEP 695 generics — the modern syntax
Section titled “PEP 695 generics — the modern syntax”This is the headline change for typing in modern Python. Pre-3.12 generics meant
declaring TypeVars by hand; 3.12+ has clean inline syntax that reads almost
exactly like TS and Go.
Type aliases
Section titled “Type aliases”type Vector = list[float] # `type` statement (PEP 695)type Pair[T] = tuple[T, T] # generic aliastype Handler = Callable[[Request], Response]Generic functions
Section titled “Generic functions”function first<T>(xs: T[]): T | undefined { return xs[0];}func First[T any](xs []T) (T, bool) { if len(xs) == 0 { var zero T return zero, false } return xs[0], true}def first[T](xs: list[T]) -> T | None: # PEP 695: type params in brackets return xs[0] if xs else NoneGeneric classes
Section titled “Generic classes”class Box<T> { constructor(private value: T) {} get(): T { return this.value; }}type Box[T any] struct { value T}func (b Box[T]) Get() T { return b.value }class Box[T]: # PEP 695: no TypeVar import, no Generic[T] base def __init__(self, value: T) -> None: self._value = value
def get(self) -> T: return self._valueBounds and constraints
Section titled “Bounds and constraints”A bound says “T must be a subtype of X” — like TS <T extends X> and Go’s
[T Constraint].
function maxOf<T extends { valueOf(): number }>(a: T, b: T): T { return a.valueOf() >= b.valueOf() ? a : b;}import "cmp"
func MaxOf[T cmp.Ordered](a, b T) T { if a >= b { return a } return b}def max_of[T: (int, float, str)](a: T, b: T) -> T: # constraint: T is one of these return a if a >= b else b
# Or an upper bound (T must be a subtype of a Protocol/class):def biggest[T: Comparable](items: list[T]) -> T: # T: Bound == <T extends Bound> ...| Concept | TypeScript | Go | Python (PEP 695) |
|---|---|---|---|
| Generic fn | function f<T>(...) | func F[T any](...) | def f[T](...) |
| Generic class | class Box<T> | type Box[T any] struct | class Box[T] |
| Upper bound | <T extends X> | [T X] (interface) | [T: X] |
| Constraint set | union in bound | union interface | [T: (A, B, C)] |
| Type alias | type V = ... | type V = ... | type V = ... |
Protocols — structural typing as a contract
Section titled “Protocols — structural typing as a contract”This is the section to read twice, because it’s where Python maps perfectly onto what you already know. Python has two ways to express “an interface”:
- ABCs (abstract base classes) — nominal: a type counts only if it
explicitly inherits the ABC. Like Java/Kotlin
interface, or TS classes withimplements. - Protocols (PEP 544) — structural: a type counts if it has the right shape, no declaration needed. This is exactly a Go interface, and exactly a TS interface.
// TS interfaces are structural: anything shaped right satisfies them.interface Reader { read(n: number): string;}
function consume(r: Reader) { /* ... */ }
// No `implements` needed — this object just has to fit.consume({ read: (n) => "x".repeat(n) });// Go interfaces are structural: implement the method set, you satisfy it.type Reader interface { Read(n int) string}
func Consume(r Reader) { /* ... */ }
// A type satisfies Reader just by having Read — never says "implements Reader".type Stub struct{}func (Stub) Read(n int) string { return "" }from typing import Protocol
# A Protocol is structural: any class with a matching `read` satisfies it,# WITHOUT inheriting Reader. This is duck typing made into a checked contract.class Reader(Protocol): def read(self, n: int) -> str: ...
def consume(r: Reader) -> None: ...
class Stub: # note: does NOT inherit Reader def read(self, n: int) -> str: return ""
consume(Stub()) # ty: ok — Stub is shaped like ReaderThat’s the whole insight: Protocol is Python’s interface in the Go/TS
sense. You define the shape where it’s used, and any conforming type fits
without a declaration. This is how you write decoupled, testable code — your
function depends on a shape, not a concrete class, so tests can pass a stub.
Protocol vs ABC — when to use which
Section titled “Protocol vs ABC — when to use which”from abc import ABC, abstractmethodfrom typing import Protocol
# ABC — nominal. Implementers MUST subclass. Use when YOU own the hierarchy# and want shared implementation, or to forbid instantiation of the base.class Storage(ABC): @abstractmethod def save(self, key: str, value: bytes) -> None: ...
class S3Storage(Storage): # must explicitly subclass def save(self, key: str, value: bytes) -> None: ...
# Protocol — structural. Implementers need only the right shape. Use when you# want to accept types you DON'T own (stdlib types, third-party classes, mocks).class SupportsSave(Protocol): def save(self, key: str, value: bytes) -> None: ...| ABC | Protocol | |
|---|---|---|
| Conformance | nominal (must subclass) | structural (right shape) |
| Closest TS analog | class X implements I | interface I |
| Closest Go analog | embedding a base | interface |
| Can shared code live in it? | yes (concrete methods) | no (just shape) |
| Accept types you don’t own? | no | yes |
| Use it when | you own the hierarchy | you describe a capability |
Reach for Protocol by default — it keeps coupling low. Reach for ABC when you genuinely want shared behavior or to prevent direct instantiation.
@runtime_checkable
Section titled “@runtime_checkable”Protocols are erased like all hints, so isinstance(x, Reader) normally fails.
Add @runtime_checkable to allow it — but it only checks method names exist,
not signatures:
from typing import Protocol, runtime_checkable
@runtime_checkableclass Reader(Protocol): def read(self, n: int) -> str: ...
isinstance(Stub(), Reader) # True — but only checks `read` EXISTS, not its typesmatch / case — structural pattern matching
Section titled “match / case — structural pattern matching”match (PEP 634) is far more than a switch. It destructures and binds while it
branches — think TS’s switch plus destructuring, or Go’s type switch plus
struct fields, in one construct.
Literals, capture, and the wildcard
Section titled “Literals, capture, and the wildcard”switch (status) { case 200: return "OK"; case 404: return "Not Found"; case 500: case 502: case 503: return "Server Error"; default: return "Unknown";}switch status {case 200: return "OK"case 404: return "Not Found"case 500, 502, 503: return "Server Error"default: return "Unknown"}match status: case 200: return "OK" case 404: return "Not Found" case 500 | 502 | 503: # `|` combines patterns (like Go's comma cases) return "Server Error" case code: # bare name CAPTURES the value (binds `code`) return f"Unknown: {code}" # `case _:` is the wildcard if you don't want to bindClass patterns — like Go’s type switch, but with destructuring
Section titled “Class patterns — like Go’s type switch, but with destructuring”This is the standout feature. You match on type and pull fields out at once:
from dataclasses import dataclass
@dataclassclass Circle: radius: float
@dataclassclass Rectangle: width: float height: float
def area(shape: Circle | Rectangle) -> float: match shape: case Circle(radius=r): # match type AND bind its field return 3.14159 * r * r case Rectangle(width=w, height=h): return w * h// Go's type switch is the closest analog, but you bind the whole value// then reach into fields manually:switch s := shape.(type) {case Circle: return math.Pi * s.Radius * s.Radiuscase Rectangle: return s.Width * s.Height}Guards, mapping, and sequence patterns
Section titled “Guards, mapping, and sequence patterns”def describe(value: object) -> str: match value: case int() | float() if value < 0: # GUARD: pattern + `if` condition return "negative number" case [first, *rest]: # SEQUENCE: bind head and tail return f"list starting with {first}, {len(rest)} more" case {"type": "user", "name": str(name)}: # MAPPING: match dict shape + bind return f"user named {name}" case str() if not value: return "empty string" case _: return "something else"Sequence patterns ([first, *rest]) and mapping patterns ({"key": value}) have
no direct TS/Go equivalent — they’re closer to destructuring assignment fused
into the branch condition. Combined with Literal unions and assert_never,
match is how you get exhaustive, type-narrowed dispatch in Python.
TypedDict, NamedTuple, and Enums
Section titled “TypedDict, NamedTuple, and Enums”TypedDict — typed shapes for plain dicts
Section titled “TypedDict — typed shapes for plain dicts”When data really is a dict (JSON payloads, config) but you want it typed,
TypedDict describes the keys. This is almost exactly a TS interface over an
object — and like TS, it’s purely structural and erased.
from typing import TypedDict, NotRequired
class UserDict(TypedDict): id: int name: str email: NotRequired[str] # optional key (like TS `email?: string`)
u: UserDict = {"id": 1, "name": "Alice"} # checker validates the shapeinterface UserDict { id: number; name: string; email?: string;}Use TypedDict when you must stay a dict (interop, JSON). When you control the
type, prefer a dataclass (module 03) or Pydantic model (module 04) — they give
you real objects, methods, and runtime validation.
NamedTuple — a lightweight typed record (brief)
Section titled “NamedTuple — a lightweight typed record (brief)”An immutable tuple with named fields. Handy for small fixed records and multiple return values:
from typing import NamedTuple
class Point(NamedTuple): x: float y: float
p = Point(1.0, 2.0)p.x # 1.0 (attribute access)px, py = p # also unpacks like a tupleEnums — Enum, StrEnum, IntEnum
Section titled “Enums — Enum, StrEnum, IntEnum”Python enums are real classes. In 2026, StrEnum is what you usually want
for string-valued enums (status fields, kinds) — members are strings, so they
serialize and compare cleanly.
enum Direction { North = "NORTH", South = "SOUTH" }// or `const Direction = {...} as const` for the lighter idiomtype Direction intconst ( North Direction = iota South)func (d Direction) String() string { return [...]string{"NORTH", "SOUTH"}[d] }from enum import Enum, StrEnum, IntEnum, auto
class Direction(Enum): NORTH = auto() # plain enum: members are unique opaque values SOUTH = auto()
class Status(StrEnum): # members ARE strings (3.11+) — best for APIs/JSON ACTIVE = "active" BANNED = "banned"
Status.ACTIVE == "active" # True — StrEnum members compare equal to their str
class Priority(IntEnum): # members ARE ints — sortable, comparable LOW = 1 HIGH = 10match pairs beautifully with enums for exhaustive dispatch:
def handle(s: Status) -> str: match s: case Status.ACTIVE: return "ok" case Status.BANNED: return "blocked" # add a member and your `match` (with assert_never) flags the gapGotchas that bite TS/Go devs specifically
Section titled “Gotchas that bite TS/Go devs specifically”These are the Python typing traps people from your background hit most. Read them once and save yourself a debugging session.
Mutable default arguments — the classic trap
Section titled “Mutable default arguments — the classic trap”Default argument values are evaluated once, at function definition, and
shared across calls. A mutable default ([], {}) becomes a persistent
shared object. This shocks everyone.
def add_item(item: str, bucket: list[str] = []) -> list[str]: # BUG bucket.append(item) return bucket
add_item("a") # ['a']add_item("b") # ['a', 'b'] <-- the SAME list! Not what you wanted.
# The fix: default to None, create inside.def add_item(item: str, bucket: list[str] | None = None) -> list[str]: if bucket is None: bucket = [] bucket.append(item) return bucketRuff flags this (B006). There’s no Go/TS analog — in both, a literal default
would be re-created each call.
Truthiness — empty containers are falsy
Section titled “Truthiness — empty containers are falsy”In Python, 0, "", [], {}, set(), and None are all falsy. This is
broader than Go (which has no truthiness — you compare explicitly) and broader
than TS in a way that bites:
items: list[str] = get_items()if not items: # True if the list is EMPTY *or* None — often what you want ...
count = 0if count: # False! 0 is falsy. If you meant "is it set", this is a bug. ...if count is not None: # check existence explicitly ...The rule: use truthiness for “empty or absent”; use explicit is None /
is not None when 0, "", or False are valid values you must distinguish
from “missing.”
is vs == — identity vs equality
Section titled “is vs == — identity vs equality”== compares values (calls __eq__); is compares object identity (same
object in memory). Always use is for None, True, False — they’re
singletons — and == for everything else.
x is None # CORRECT — identity check against the None singletonx == None # works but wrong style; ty/ruff nudge you to `is None`
a = [1, 2]b = [1, 2]a == b # True (same value)a is b # False (different objects) — like JS `===` on objects, Go ptr ==Coming from Go, think: is ≈ comparing pointers; == ≈ comparing values (Go
struct ==). From TS: is ≈ === on object references; == ≈ deep value
equality.
None checks narrow the type
Section titled “None checks narrow the type”Like TS narrowing and Kotlin smart casts, the checker tracks None checks:
def name_length(name: str | None) -> int: if name is None: return 0 return len(name) # ty knows `name` is `str` here — no None to worry aboutGradual typing, Any, cast, and # type: ignore
Section titled “Gradual typing, Any, cast, and # type: ignore”Python’s typing is gradual: untyped code and typed code coexist, and Any is
the seam between them. Any is the escape hatch that opts out of checking —
anything goes in, anything comes out. It’s contagious and dangerous; treat it
like TS any, not like unknown.
from typing import Any, cast
data: Any = parse_untyped() # checker stops checking anything touching `data`
# `cast` tells the checker "trust me, it's this type" — no runtime effect at all# (it's TS `as`, not a real conversion). Use sparingly, at trust boundaries.user = cast(dict[str, int], data)
# `# type: ignore` silences ONE line. Always scope it and say why:result = legacy_lib.do_thing() # type: ignore[no-untyped-call] # vendor has no stubsDiscipline that pays off:
- Prefer
objectoverAnywhen you just need “some value” —objectstays checked (you must narrow before use), like TSunknown. - Never blanket-
# type: ignorea file. Scope to the specific error code. castis a promise to the checker, not a conversion — if you’re wrong, you get a real runtime bug with no warning.
Verify it: run the checker
Section titled “Verify it: run the checker”Type hints are worthless if nothing checks them. In a uv project, run ty:
uv run ty check # check the whole project (Astral's fast checker)uv run ty check src/app/main.py # one filemypy is the mature, established alternative with the same job — if a project
already uses it, uv run mypy src is the equivalent. We show ty because it’s
fast and from the same people as uv/ruff, so the toolchain is consistent.
Practice
Section titled “Practice”Put the type system to work: parse a config from a dict/env into a fully-typed
structure using only the stdlib and typing — dataclasses, a Protocol for a
pluggable source, Literal, generics, and an exhaustive match.