Type Checkers

Type hints by themselves are just annotations Python ignores at runtime — static type checkers like mypy and pyright are the tools that actually read them and catch type errors before your code ever runs.

Python's type hint syntax (def f(x: int) -> str: ...) is, by design, not enforced by the interpreter. Nothing stops you from calling f("oops") and having it run right up until it fails for an unrelated reason. The value of type hints comes from external tools — static type checkers — that read your annotations and your code and report mismatches before you ever execute anything. This is Python's "gradual typing" model: fully optional, incrementally adoptable, and separate from the runtime.

What Is a Type Checker

A static type checker is a separate program that reads your Python source code (including its type annotations) and analyzes it WITHOUT running it, flagging places where the declared types don't line up — a str passed where an int is expected, a method called that doesn't exist on the given type, a None used where it wasn't declared possible. The two dominant type checkers in the Python ecosystem are mypy and pyright, both third-party tools (not part of the standard library).

mypy and pyright are third-party tools, installed separately (pip install mypy or via npm/editor extension for pyright) — the standard library provides the typing module (the annotation syntax and constructs) but ships no built-in checker.

Why Type Checkers Matter

Type hints alone are inert documentation without a checker reading them. Running a type checker in your editor and in CI turns those hints into an active safety net: typos in attribute names, wrong argument types, forgotten None checks, and incompatible return types get caught during development or in a pull request — long before they'd surface as a runtime AttributeError or TypeError in production.

def greet(name: str) -> str:
    return "Hello, " + name

greet(42)  # mypy/pyright: error — Argument 1 has incompatible type "int"; expected "str"
           # Python itself: raises TypeError only when this line actually executes

mypy

mypy is the original and most widely adopted Python type checker, developed with close ties to the typing module's evolution. It runs from the command line and integrates into most editors and CI pipelines.

mypy is configured via a mypy.ini, setup.cfg, or (commonly today) a [tool.mypy] section in pyproject.toml, controlling strictness flags, which paths to check, and how to handle untyped third-party libraries.

$ pip install mypy
$ mypy my_module.py
my_module.py:5: error: Argument 1 to "greet" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 1 source file)

pyright

pyright is a fast type checker from Microsoft, written in TypeScript, and is the engine behind the Pylance extension in VS Code — meaning many developers run pyright continuously in their editor without realizing it, in addition to (or instead of) running mypy in CI. It is also runnable standalone from the command line.

mypy and pyright implement the same PEP-defined typing semantics but are separate codebases with separate edge-case behaviors and strictness defaults; a project can standardize on either (or both) — it's common for teams to use Pylance/pyright for live editor feedback and mypy in CI, or vice versa.

$ npm install -g pyright
$ pyright my_module.py
my_module.py:5:11 - error: Argument of type "int" cannot be assigned to
  parameter "name" of type "str"

Gradual Typing

Python's typing model is "gradual": you can type-hint some functions and leave others untouched, and a type checker treats unannotated code permissively (as implicitly Any-typed) rather than refusing to run. This lets you adopt typing incrementally in an existing codebase — module by module, function by function — instead of requiring an all-or-nothing rewrite.

def typed_fn(x: int) -> int:
    return x * 2

def untyped_fn(x):        # implicitly Any — checker mostly skips checking it
    return x * 2

typed_fn(untyped_fn(5))   # checker doesn't complain: untyped_fn's return is Any

Strictness Levels

Both mypy and pyright offer a spectrum from very permissive to very strict. Any-typed and unannotated code is allowed by default; a stricter mode (--strict in mypy, "strict" typeCheckingMode in pyright) requires nearly everything to be annotated and flags implicit Any usage, catching far more but demanding much more upfront typing discipline.

$ mypy --strict my_module.py   # requires full annotation coverage, flags implicit Any
$ mypy my_module.py            # default: more permissive, good for incremental adoption

Runtime Type Hints Do Not Validate Values

This is the single most important fact about type checkers: they run as a SEPARATE, OFFLINE analysis step. Python itself never consults your type hints when running code — passing a value of the wrong type does not raise a TypeError from the annotation alone; it only fails later if the mismatched type actually causes an operation to break.

If you need actual runtime validation of values (e.g. untrusted input from a network request), type hints are not enough on their own — use explicit isinstance() checks, assert statements, or a validation-focused third-party library like pydantic.

def add(a: int, b: int) -> int:
    return a + b

result = add("5", "3")   # Python runs this line without complaint
print(result)             # "53" — string concatenation, not addition; no exception raised

result2 = add("5", 3)     # this DOES raise TypeError at runtime — but from + on str/int,
                           # not from the type hints, which are never consulted

Type Checkers vs Other Tools

Type checkers analyze types; they are distinct from linters (like ruff or flake8, which flag style and some correctness issues) and formatters (like black, which rewrite code style). All three are commonly run together in CI, but each serves a different purpose and none of them substitute for the others.

mypy / pyright   -> static type checking (types match declarations)
ruff / flake8    -> linting (style, unused imports, some bug patterns)
black / ruff fmt -> formatting (consistent code layout)

Practical Usage

A typical workflow: annotate function signatures and complex data structures as you write them, run the checker locally (often live, via an editor plugin) for immediate feedback, and enforce the check as a required CI step so type errors can't merge silently. Many teams start permissive and ratchet up strictness over time as coverage improves.

Key Takeaways

Type checkers are separate, third-party tools (mypy, pyright) that statically analyze your type-hinted code — they are not part of the Python interpreter or standard library.

Python itself never enforces type hints at runtime; a value of the "wrong" type is happily accepted unless a later operation on it independently raises an error.

Gradual typing means annotations are optional and incrementally adoptable — unannotated code is treated permissively, not rejected.

mypy and pyright both implement the same PEP-based typing semantics but are separate implementations with their own strictness defaults and edge-case behavior.

For actual runtime value validation (not just static analysis), use explicit checks or a dedicated validation library — type hints alone do not provide it.

def process(age: int) -> str: return f"Age: {age}" process("twenty") # assumed to raise TypeError immediately... it doesn't! # f-string formatting works fine on a str too — no error at all here

Type hints are not runtime-enforced; passing the wrong type silently 'works' whenever the mismatched value happens not to break any actual operation. Only a static checker catches the mismatch.

Writing type hints has zero effect unless a type checker is actually run against the code — hints alone are inert without the tool reading them.

def load_config(data: dict) -> None: port: int = data["port"] # assumes this is always an int print(port + 1) # Untrusted external JSON might have port as a string — type hint doesn't stop it def load_config(data: dict) -> None: port = data["port"] if not isinstance(port, int): raise TypeError(f"port must be int, got {type(port).__name__}") print(port + 1)

For untrusted external data (parsed JSON, user input, network responses), a type hint provides no runtime guarantee — add an explicit isinstance() check or use a validation library.

Does Python raise an error at runtime if you call a function with an argument type that violates its annotation?

No — Python never consults type hints at runtime. The call runs normally unless the mismatched type independently causes an operation (like + between incompatible types) to fail.

Name two widely used third-party static type checkers for Python, and clarify whether they ship with the standard library.

mypy and pyright. Neither ships with the standard library — the stdlib provides the typing module (the annotation syntax/constructs) but no built-in checker; both must be installed separately.

What does 'gradual typing' mean in Python's type system?

It means type annotations are optional and can be adopted incrementally — unannotated code is treated permissively (implicitly Any) rather than rejected, so a codebase can be typed function-by-function or module-by-module.

What is the practical difference between mypy's default mode and its --strict mode?

Default mode is more permissive and good for incremental adoption on an existing codebase; --strict requires near-complete annotation coverage and flags implicit Any usage, catching far more issues but demanding more upfront typing discipline.

If you need to validate that data received over a network actually matches its declared type at runtime, are type hints sufficient on their own?

No — type hints provide no runtime validation. You need explicit isinstance() checks, assertions, or a dedicated validation library (e.g. pydantic, a third-party tool) to actually enforce types at runtime.

How do type checkers differ from linters and formatters?

Type checkers (mypy, pyright) analyze whether types match declarations; linters (ruff, flake8) flag style and correctness issues unrelated to types; formatters (black) rewrite code layout for consistency. They serve distinct purposes and are often run together.

Where does pyright commonly run besides the command line?

It powers the Pylance extension in VS Code, so many developers get live pyright feedback in their editor continuously, in addition to or instead of running it (or mypy) as a standalone CI step.

Would a static type checker flag this call, and what happens when this code actually runs in plain Python?

A static type checker (mypy/pyright) would flag total(["1","2","3"]) as an error since list[str] doesn't match the declared list[int]. But plain Python runs it anyway: sum() on a list of strings raises TypeError: unsupported operand type(s) for +: 'int' and 'str' at runtime — a real exception, but from sum()'s own logic, not from the type hints (which Python never consults).

Write a function safe_divide(a, b) with int type hints, then add an explicit runtime isinstance() check so it actually rejects non-int arguments instead of relying on the type hints alone.

def safe_divide(a: int, b: int) -> float: if not isinstance(a, int) or not isinstance(b, int): raise TypeError("both arguments must be int") if b == 0: raise ValueError("b must not be zero") return a / b print(safe_divide(10, 2)) # 5.0 safe_divide("10", 2) # raises TypeError from the explicit check, not from the hints

The type hints alone would not stop safe_divide('10', 2) from running; the explicit isinstance() check is what actually enforces the type at runtime, since Python itself ignores annotations during execution.

Explain, in code-comment form, why running mypy script.py is a separate step from running python script.py, using a short example.

def add_one(x: int) -> int: return x + 1 # Step 1 (static, offline, no code runs): mypy checks the annotation # $ mypy script.py # -> could report an error if add_one is called elsewhere with a str # Step 2 (dynamic, executes the code): python script.py # -> runs add_one(5) etc.; never looks at the "x: int" hint at all print(add_one(5)) mypy performs static analysis without executing the program — it only reads source code and annotations to look for mismatches. python script.py actually executes the code, and the interpreter never reads or enforces the type hints during that execution; the two are entirely separate processes run at different times for different purposes.

def total(prices: list[int]) -> int:
    return sum(prices)

result = total(["1", "2", "3"])
print(result)