Skip to content

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

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.

TypeScriptGoPython
Types checkedat build (tsc)at build (compiler)by a separate tool (ty/mypy)
Types at runtimeerasedenforcederased (just __annotations__ metadata)
Can you run code that fails the checker?yes (emit-on-error)noyes — the interpreter never looks
Gradual / opt-in typingyesnoyes

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: int in 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.

Annotate with name: Type. Return types go after ->.

age: int = 30
name: 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:

TypeScriptGoPythonNotes
numberint / int64intPython ints are arbitrary precision — no overflow
numberfloat64floatalways 64-bit
booleanboolbool
stringstringstr
T[] / Array<T>[]Tlist[T]mutable, ordered
[A, B] (tuple)tuple[A, B]fixed-size; tuple[int, ...] = variadic
Record<string, V>map[string]Vdict[str, V]
Set<T>map[T]struct{}set[T]
T | null*TT | None
A | Binterface / anyA | Bunion
unknownanyobjectsafe top type
anyany / interface{}Anyescape hatch (opts out of checking)
voidNone
neverNeverbottom 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, Set
def 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 expect

X | 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> = {};

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.

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") # ok
request("PATCH", "/users") # ty error: "PATCH" is not a valid Method
// TS equivalent — identical idea
type 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.

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 error
API_BASE: Final[str] = "https://api.example.com"
MAX_RETRIES = 5 # ty error: cannot assign to Final

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 job
class 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 HERE

If 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.

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 Vector = list[float] # `type` statement (PEP 695)
type Pair[T] = tuple[T, T] # generic alias
type Handler = Callable[[Request], Response]
function first<T>(xs: T[]): T | undefined {
return xs[0];
}
class Box<T> {
constructor(private value: T) {}
get(): T { return this.value; }
}

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;
}
ConceptTypeScriptGoPython (PEP 695)
Generic fnfunction f<T>(...)func F[T any](...)def f[T](...)
Generic classclass Box<T>type Box[T any] structclass Box[T]
Upper bound<T extends X>[T X] (interface)[T: X]
Constraint setunion in boundunion interface[T: (A, B, C)]
Type aliastype 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 with implements.
  • 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) });

That’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.

from abc import ABC, abstractmethod
from 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: ...
ABCProtocol
Conformancenominal (must subclass)structural (right shape)
Closest TS analogclass X implements Iinterface I
Closest Go analogembedding a baseinterface
Can shared code live in it?yes (concrete methods)no (just shape)
Accept types you don’t own?noyes
Use it whenyou own the hierarchyyou 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.

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_checkable
class Reader(Protocol):
def read(self, n: int) -> str: ...
isinstance(Stub(), Reader) # True — but only checks `read` EXISTS, not its types

match / 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.

switch (status) {
case 200: return "OK";
case 404: return "Not Found";
case 500: case 502: case 503: return "Server Error";
default: return "Unknown";
}

Class 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
@dataclass
class Circle:
radius: float
@dataclass
class 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.Radius
case Rectangle:
return s.Width * s.Height
}
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 — 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 shape
interface 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 tuple

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 idiom

match 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 gap

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 bucket

Ruff flags this (B006). There’s no Go/TS analog — in both, a literal default would be re-created each call.

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 = 0
if 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.”

== 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 singleton
x == 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.

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 about

Gradual 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 stubs

Discipline that pays off:

  • Prefer object over Any when you just need “some value” — object stays checked (you must narrow before use), like TS unknown.
  • Never blanket-# type: ignore a file. Scope to the specific error code.
  • cast is a promise to the checker, not a conversion — if you’re wrong, you get a real runtime bug with no warning.

Type hints are worthless if nothing checks them. In a uv project, run ty:

Terminal window
uv run ty check # check the whole project (Astral's fast checker)
uv run ty check src/app/main.py # one file

mypy 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.

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.