pyforjs home

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 / ZodPython 3.14+
string | numberstr | 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: stringx: Final[str], or a frozen dataclass
function f<T>(x: T): Tdef 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.

structural.py
Starting Python…

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.

pydantic_basics.py
Starting Python…

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.

ZodPydantic 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:

pydantic_unions.py
Starting Python…

The toolchain

JavaScriptPythonNotes
npm / pnpmuvresolves, locks and creates venvs in milliseconds
pnpm-workspace.yaml[tool.uv.workspace]monorepo members under one lockfile
package.jsonpyproject.tomldeps, build config and tool config in one file
package-lock.jsonuv.lock
npxuvx / uv run script.pyrun a tool or a self-declaring script
ESLint + Prettierruff check + ruff formatone binary, sub-millisecond
tsc --noEmitpyright / mypy
Jest / Vitestpytest
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 way

There 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 machine

This 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 PyPI
uv 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 member

Where 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 here
ruff check --fix .   # lint + autofix (includes import sorting)
ruff format .        # format, this replaces Black entirely

UP 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) == expected

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

kata_04.py
Starting Python…

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.

On this page