Cloud Parts

Linters, formatters and type checkers

Tools that read source code without running it: linters flag likely bugs, formatters reprint the layout, and type checkers check that values have the types the code claims.

4 technologies on this page ↓

How it works

All three start the same way: they parse each file into a syntax tree, without running anything. A linter walks that tree and applies rules, each a small check such as “this import is never used” or “this comparison is always true”; many rules come with an automatic fix. Ruff has over 900 rules for Python, Clippy over 800 lints for Rust, and ESLint hundreds for JavaScript. A formatter ignores how the code was laid out and prints the tree again in one canonical style, so spacing, quotes and line breaks stop being a matter of taste; Black even checks that the reformatted code still parses to an equivalent tree. A type checker works out the type of every expression, from annotations where they exist and by inference where they don’t, and reports where a value can’t have the type the code needs, such as an int passed where a str is expected. In Python and with TypeScript’s tsc, this happens entirely before the program runs, and Python ignores annotations at runtime. The three overlap little: a linter sees an unused import that a type checker skips, and a type checker sees a wrong argument type that a linter misses. Teams usually run all three on save in the editor (through a language server), again before commit, and in CI with a check-only flag so the build fails on problems but never rewrites code.

Think of it as

A manuscript before printing. The typesetter (formatter) fixes spacing and margins without touching the words. The copy editor (linter) works through a checklist of common mistakes and corrects most of them. The fact-checker (type checker) checks every claim against the references and only reports what doesn’t match.

Reach for it when

  • Any codebase with more than one author: a formatter ends style debates in review because there is nothing left to decide
  • In CI, with check-only flags (ruff format --check, ruff check, a type checker’s exit code) so a pull request can’t merge with findings
  • Dynamically typed code (Python, JavaScript through TypeScript) where a wrong type would otherwise only show up at runtime

Think twice when

  • Switching on every rule in an old codebase at once; the thousands of findings get ignored. Start small, or record existing errors as a baseline (basedpyright --writebaseline, pyrefly suppress)
  • Treating a clean type check as proof the program works: Python doesn’t enforce annotations at runtime, and untyped libraries or Any hide errors
  • Alternating two formatters on the same files: Ruff, for one, says its formatter isn’t meant to be used interchangeably with Black

A real example

A small Python service has a 17-line module with three issues and a bug. ruff check reports three findings in milliseconds (unsorted imports, an unused import os, an old-style Optional[str]), and ruff check --fix repairs all three. ruff format changes one line, orders=[...] to orders = [...]. Neither notices that report() assigns the int returned by total() to a variable declared str; the type checker does, at line 16, column 18. After the fix, CI runs uv sync --locked, ruff check, ruff format --check and basedpyright in sequence, and all four exit with code 0.

Kinds of linters, formatters and type checkers

Specific products, and what each does differently.

Three tools, three questions#

They read the same syntax tree and answer different questions, which is why projects run all three.

KindQuestion it answersChanges your code?Examples
LinterIs this code probably wrong, risky or outdated?Only fixes you ask for (--fix)Ruff, ESLint, Clippy, go vet
FormatterIs it laid out the canonical way?Yes, that is its job; --check only reportsgofmt, Prettier, Black, ruff format
Type checkerCan every value have the type the code needs?No, it only reportsTypeScript’s tsc, mypy, Pyright, basedpyright, Pyrefly, ty

One Python project, step 1: uv creates it and adds the tools#

uv (0.13.0 here) creates the project, records Pydantic as a dependency and Ruff plus basedpyright as development dependencies in [dependency-groups], and pins all of them in uv.lock. Everyone who runs uv sync gets the same tool versions, so the editor, a teammate’s laptop and CI report the same findings. Output trimmed where marked.

bash
uv init shop && cd shop          # pyproject.toml, .python-version, src/shop/__init__.py
uv add pydantic
# Using CPython 3.12.13
# Creating virtual environment at: .venv
# Resolved 6 packages in 12ms
#  + pydantic==2.14.0             (plus its 4 dependencies and the project itself)

uv add --dev ruff basedpyright
# Resolved 9 packages in 6ms
#  + basedpyright==1.40.2
#  + nodejs-wheel-binaries==24.19.0   (basedpyright bundles Node.js, so no npm is needed)
#  + ruff==0.17.0

Step 2: one pyproject.toml configures all three#

uv init wrote the first four tables and uv add filled in the dependencies; the [tool.*] tables at the end are added by hand. Ruff reads [tool.ruff]; basedpyright reads [tool.basedpyright] and finds the .venv uv created without being told. Its own default mode, recommended, turns on every rule, so this project starts from Pyright’s standard set.

text
[project]
name = "shop"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
    "pydantic>=2.14.0",
]

[project.scripts]
shop = "shop:main"

[build-system]
requires = ["uv_build>=0.13.0,<0.14.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
    "basedpyright>=1.40.2",
    "ruff>=0.17.0",
]

[tool.ruff]
line-length = 100

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]   # pycodestyle, Pyflakes, isort, bugbear, pyupgrade

[tool.basedpyright]
typeCheckingMode = "standard"

Step 3: a module with three lint findings, one layout slip and one bug#

src/shop/orders.py. The bug is in report(): total() returns an int, but the result is assigned to a variable declared str. Python would run this without complaint.

python
import os
from typing import Optional
from pydantic import BaseModel


class Order(BaseModel):
    id: int
    total_cents: int
    note: Optional[str] = None


def total(orders: list[Order]) -> int:
    return sum(o.total_cents for o in orders)


def report() -> str:
    orders=[Order(id=1, total_cents=1299), Order(id=2, total_cents=450)]
    label: str = total(orders)
    return label

Step 4: lint, fix, format, type-check#

Real output from Ruff 0.17.0 and basedpyright 1.40.2 (based on Pyright 1.1.414); long paths shortened to …/shop. Each tool exits with code 1 when it finds something, which is what fails a CI job.

bash
uv run ruff check --output-format concise
# src/shop/orders.py:1:1: I001 [*] Import block is un-sorted or un-formatted
# src/shop/orders.py:1:8: F401 [*] `os` imported but unused
# src/shop/orders.py:9:11: UP045 [*] Use `X | None` for type annotations
# Found 3 errors.
# [*] 3 fixable with `--fix`.

uv run ruff check --fix          # sorts imports, drops os and Optional, writes str | None
# Found 3 errors (3 fixed, 0 remaining).

uv run ruff format               # one change: orders=[...] becomes orders = [...]
# 1 file reformatted, 2 files left unchanged

uv run basedpyright              # the bug Ruff can’t see
# …/shop/src/shop/orders.py
#   …/shop/src/shop/orders.py:16:18 - error: Type "int" is not assignable to declared type "str"
#     "int" is not assignable to "str" (reportAssignmentType)
# 1 error, 0 warnings, 0 notes

Step 5: fix the bug, then run what CI runs#

With report() changed to return f"{total(orders) / 100:.2f} EUR", the whole gate passes. --locked makes uv sync fail if uv.lock is stale instead of updating it.

bash
uv sync --locked && uv run ruff check && uv run ruff format --check && uv run basedpyright
# Resolved 9 packages in 5ms
# Checked 9 packages in 1ms
# All checks passed!
# 3 files already formatted
# 0 errors, 0 warnings, 0 notes

uv run python -c "from shop.orders import report; print(report())"
# 17.49 EUR

Swapping the type checker: Pyright and Pyrefly#

The checkers are interchangeable at this level, with two traps worth knowing. A checker started outside the project environment can’t see its dependencies. And Pyrefly 1.3, finding no [tool.pyrefly] table, chose its low-noise basic preset, which skips assignment checks; asking for the default preset (or adding the table) finds the bug. Run on the file from step 4, before the fix.

bash
uvx pyright                      # runs in uvx’s own environment, not the project’s .venv
#   …/shop/src/shop/orders.py:1:6 - error: Import "pydantic" could not be resolved (reportMissingImports)
uv run --with pyright pyright    # same checker inside the project environment
#   …/shop/src/shop/orders.py:16:18 - error: Type "int" is not assignable to declared type "str"

uvx pyrefly check
#  INFO 0 errors
# No `pyrefly.toml` found — using preset `basic`.
uvx pyrefly check --preset default
# ERROR `int` is not assignable to `str` [bad-assignment]
#   --> src/shop/orders.py:16:18
#  INFO 1 error

In code · python

import os  # linter: F401, `os` imported but unused


# formatter: reprints the next line as `def total(prices: list[int]) -> int:`
def total(prices:list[int])->int:
    return sum(prices)


# type checker: "int" is not assignable to "str"
label: str = total([1299, 450])

# linter: E711, comparison to `None` should be `is None`
if label == None:
    print("empty")

print(label)  # runs anyway and prints 1749: Python doesn't check annotations

Often confused with

Where it came from1978–2025

In 1978 Stephen C. Johnson at Bell Labs was writing a yacc grammar for C and dealing with portability problems from porting Unix to a 32-bit machine. The checker Johnson wrote, lint, shipped outside Bell Labs with Unix V7 in 1979 and gave its name to a whole category of tools, even though the original did static analysis rather than style checks.

Formatters took longer to win. Go’s 2013 blog post on gofmt could already report that about 70% of Go packages in the wild were formatted with it. JavaScript followed with Prettier in 2017 and Python with Black, both selling the same deal: give up control over layout and never discuss it again.

Optional types for dynamic languages grew alongside. Jukka Lehtosalo started mypy in 2012, and PEP 484 (2014) turned its annotation style into standard Python syntax while leaving checking to outside tools, which is why Python now has several competing checkers. Since 2022 a wave of tools rewritten in Rust has made all three kinds much faster: Ruff for linting and formatting, then Pyrefly and ty for types.

Why it’s built this way

Reprint everything instead of flagging style
A formatter that rebuilds the layout from the syntax tree leaves nothing to argue about, and frees linters to focus on rules that catch real bugs.
Few options, on purpose
Prettier’s maintainers found that every option reopened the style debate as a debate about options; Black asks users to cede control in return for speed and determinism.
Optional types, checked by a separate tool
PEP 484 kept annotations out of the runtime so existing code kept working and checking stayed voluntary, letting projects add types gradually.
Rewrite the tools in a compiled language
Ruff’s author argued Python tooling could be much faster, as esbuild and swc had shown for JavaScript: linting CPython, Ruff’s first release was about 150 times faster than Flake8 on macOS (about 25 times with Flake8 hacked to use multiprocessing).

Timeline

  1. 1978

    Stephen C. Johnson writes lint for C at Bell Labs

  2. 2012

    Jukka Lehtosalo starts mypy

  3. 2013

    About 70% of Go packages already use gofmt

  4. 2014

    PEP 484 proposes standard type hints for Python 3.5

  5. 2017

    Prettier 1.0 for JavaScript

  6. 2022

    Ruff, a Python linter written in Rust

  7. 2025

    Rust-based Python type checkers: Pyrefly (alpha, May) and ty (beta, December)

Sources20

Improve this page on GitHub