Typing, modern tooling & testing
TypeScript's structural types map onto Protocol and TypedDict. Then swap npm, ESLint, Prettier and Jest for uv, ruff and pytest.
Python's type system arrived later than TypeScript's and works the same way in the part that matters. Annotations are erased at runtime and checked by a separate tool. What differs is that Python also has a first-class runtime validation story, and you will use both.
The rosetta
| TypeScript / Zod | Python 3.14+ |
|---|---|
string | number | str | int |
string[] | list[str] |
Record<string, User> | dict[str, User] |
type UserMap = ... | type UserMap = dict[str, User], the type statement (3.12) |
interface User { id: string } | class User(Protocol): id: str, structural |
Partial<User> | class User(TypedDict, total=False) |
readonly x: string | x: Final[str], or a frozen dataclass |
function f<T>(x: T): T | def f[T](x: T) -> T: (3.12) |
z.object({ age: z.number().min(0) }) | class User(BaseModel): age: NonNegativeInt |
The important one is Protocol. It is structural, exactly like a TypeScript interface. A class
satisfies it by having the right members, no inheritance, no registration.
Annotations do nothing at runtime
def f(x: int) will happily accept a string. Type hints are checked by pyright or mypy before
you ship, exactly like tsc. If you need enforcement at an input boundary, that is validation,
not typing.
3.14 changed how annotations are stored
Since 3.14 (PEP 649) annotations are evaluated lazily, the expression is kept as a string and only
evaluated when something asks for it. from __future__ import annotations is now a no-op and
deprecated. Nothing else changed. Hints still do nothing at runtime, and enforcement at a trust
boundary is still pydantic's job.
Pydantic is Zod, not TypeScript
Static hints cover your own code. Anything crossing a trust boundary, an HTTP body, a config file, an LLM response, needs runtime validation. Pydantic v2 generates a validator from the same class you would have written anyway.
The first run downloads the Pydantic wheel into the browser, so give it a moment.
The Zod Rosetta
The conceptual flip is this. In Zod you build a schema object, then z.infer pulls the
static type out of it. In Pydantic the class is both the static type and the
runtime validator. There is nothing to infer.
| Zod | Pydantic v2 |
|---|---|
z.object({...}) | class M(BaseModel): ... |
schema.parse(raw) | M.model_validate(raw), raises on invalid |
schema.safeParse(raw) | try/except around model_validate |
z.infer<typeof Schema> | the class itself, no inference step |
z.string().min(1) | str = Field(min_length=1) |
.refine(fn) | @field_validator / @model_validator(mode="after") |
.transform(fn) | @computed_field, or mutate self in an after-validator |
z.discriminatedUnion("kind", [...]) | Annotated[A | B, Field(discriminator="kind")] |
schema.toJSONSchema() | M.model_json_schema() |
V2 gives you two things Zod does not. Validation compiled to Rust (pydantic-core), and
coercion by default. "7" becomes 7. If you want Zod's strictness, opt in per model with
model_config = ConfigDict(strict=True).
Discriminated unions are the pattern you will reach for most, every LLM tool-choice and webhook event envelope is one:
The toolchain
| JavaScript | Python | Notes |
|---|---|---|
npm / pnpm | uv | resolves, locks and creates venvs in milliseconds |
pnpm-workspace.yaml | [tool.uv.workspace] | monorepo members under one lockfile |
package.json | pyproject.toml | deps, build config and tool config in one file |
package-lock.json | uv.lock | |
npx | uvx / uv run script.py | run a tool or a self-declaring script |
| ESLint + Prettier | ruff check + ruff format | one binary, sub-millisecond |
tsc --noEmit | pyright / mypy | |
| Jest / Vitest | pytest |
uv init myservice && cd myservice
uv add pydantic httpx
uv add --dev pytest ruff pyright
uv run pytest # creates and syncs the venv on the wayThere is no node_modules. uv builds a .venv from a global content-addressed cache, which
is why it is fast and why the directory is disposable.
Single-file scripts with PEP 723
npx-style single-file scripts get their own format. A comment block at the top declares the
script's dependencies, and uv run builds a throwaway environment for it, no venv to activate,
nothing to install first:
# /// script
# requires-python = ">=3.13"
# dependencies = ["httpx"]
# ///
import httpx
print(httpx.get("https://example.com").status_code)uv run scrape.py # resolves deps, caches the env, runs, repeatable on any machineThis replaces the pip install -r requirements.txt && source .venv/bin/activate steps for one-off scripts.
Monorepos with uv workspaces
A uv workspace works like pnpm-workspace.yaml. One root manifest pins one lockfile over many
packages, with local packages importable from each other by name.
# root pyproject.toml, the pnpm-workspace.yaml analogue
[tool.uv.workspace]
members = ["packages/*", "apps/*"]
[tool.uv.sources]
shared = { workspace = true } # "link" protocol: resolve shared/ locally, never from PyPIuv sync --all-packages # one lockfile, every member installed editable
uv add --package apps/api fastapi # add a dep to one member without cd-ing there
uv run --package worker pytest # run tests for just one memberWhere Turborepo would orchestrate builds across your pnpm workspace, the common Python answer is
simpler. Each package's tasks are plain [tool.hatch.envs...]/Makefile targets invoked via
uv run --package <member>, the dependency graph comes from the workspace itself.
Ruff replaces ESLint, Prettier, Black and isort
One config block in pyproject.toml, one command in CI:
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # pycodestyle, pyflakes, isort, pyupgrade, bugbear
[tool.ruff.format]
quote-style = "double" # prettier-style knobs live hereruff check --fix . # lint + autofix (includes import sorting)
ruff format . # format, this replaces Black entirelyUP is the rule family worth knowing about. It rewrites pre-3.10 idioms to modern ones
(Optional[X] → X | None, %-format → f-string), the equivalent of ESLint's plugin that
upgrades your syntax to what your target runtime supports.
pytest
Two things replace most of what Jest gives you.
Fixtures replace beforeEach and manual mock wiring. A fixture is a function; requesting it by
parameter name is the injection.
@pytest.mark.parametrize is test.each, with every case reported as its own test.
import pytest
@pytest.fixture
def db():
conn = connect(":memory:")
yield conn # everything after yield is teardown
conn.close()
def test_insert(db): # requested by name
assert insert(db, "ada") == 1
@pytest.mark.parametrize("value,expected", [(0, "zero"), (1, "one"), (-1, "negative")])
def test_describe(value, expected):
assert describe(value) == expectedAssertions are plain assert. pytest rewrites the bytecode to produce the diff, so there is no
expect(...).toEqual(...) vocabulary to learn.
Kata
Validate at the boundary, the way you would with a Zod schema.
Two hooks, two moments
@field_validator("sku") runs on one field after its type has been coerced.
@model_validator(mode="after") runs once the whole model is built, which is where cross-field
rules belong. Use Annotated[int, Field(ge=1)] for anything a constraint can express, it produces
a better error message than a hand-written validator.
Asynchronous runtimes & concurrency
Node's event loop is always running; Python's is not. Lazy coroutines, explicit scheduling, and the GIL's effect on which concurrency primitive you actually want.
The AI engineering capstone
Typed tool schemas from Pydantic, cosine similarity over embeddings with numpy, and an agent loop that dispatches through a tool registry, all assembled in the browser.