Skip to content

How mypy, ty, and Pyrefly handle untyped code

Run three Python type checkers over the same unannotated function and you get three different answers. One stays silent, one reports an error, and the third does either depending on whether a config file exists. None of them is wrong. They start from different assumptions about what to do with code you never annotated.

That single decision, what to do with untyped code, explains most of the friction that teams hit when they switch checkers. A codebase that passes mypy cleanly can light up with errors the first time you run ty or Pyrefly, without a single annotation having changed.

Separate body checking from inference

Body checking and inference are separate choices. A checker can inspect a function’s body while exposing its return to callers as Unknown.

Annotation-gated checkers treat annotations as the signal to check. Code you haven’t annotated keeps its dynamic behavior and is largely left alone. mypy is the canonical example: by default it does not check the body of a function that has no annotations on its parameters or return. This is gradual typing working as designed. You add annotations where you want static guarantees and the rest of the code runs untouched.

Body-checking policies analyze expressions inside unannotated functions. ty and Pyright do this by default; Pyrefly does it under its configured default preset. Return inference is a separate setting in ty and Pyrefly.

How mypy handles untyped code

mypy is the annotation-gated checker. It reads the signature of every function but checks the body only when that signature carries annotations. A function with no annotations on its parameters or return is skipped, which is the single most surprising thing about mypy for people who expect it to catch obvious bugs everywhere.

The default skips unannotated function bodies

The literal type error below goes unreported because the function carries no annotations:

$ cat body.py
def f():
    return 1 + "x"          # literal error inside a fully unannotated body

$ mypy body.py
Success: no issues found in 1 source file

Turn on --check-untyped-defs and mypy analyzes the body:

$ mypy --check-untyped-defs body.py
body.py:2: error: Unsupported operand types for + ("int" and "str")  [operator]
Found 1 error in 1 file (checked 1 source file)

Checking a body is not the same as requiring annotations

Two flags are easy to confuse. --check-untyped-defs analyzes unannotated bodies without demanding annotations. --disallow-untyped-defs does the reverse: it requires every function to be annotated and reports the missing annotation rather than the bug inside it.

$ mypy --disallow-untyped-defs body.py
body.py:1: error: Function is missing a return type annotation  [no-untyped-def]
Found 1 error in 1 file (checked 1 source file)

--strict turns on both, so the same unannotated function reports the missing annotation and the operator error together:

$ mypy --strict body.py
body.py:1: error: Function is missing a return type annotation  [no-untyped-def]
body.py:2: error: Unsupported operand types for + ("int" and "str")  [operator]
Found 2 errors in 1 file (checked 1 source file)

How to configure mypy strict mode explains the preset and how to inspect its current flag bundle.

An untyped call returns Any

The return value of an unannotated function is Any to its callers. mypy then checks nothing done with that value, which is how untyped code spreads Any into annotated code:

$ cat ret.py
def get_id():
    return 1
r = get_id()
reveal_type(r)
r.upper()                   # int has no .upper(), but r is Any

$ mypy ret.py
ret.py:4: note: Revealed type is "Any"
Success: no issues found in 1 source file

r is Any, so r.upper() passes even though get_id() returns an int. To flag the call itself, use --disallow-untyped-calls:

$ mypy --disallow-untyped-calls ret.py
ret.py:3: error: Call to untyped function "get_id" in typed context  [no-untyped-call]

Third-party imports without stubs become Any

With cowsay installed in the environment used by the checker, import a package that ships no py.typed marker or stubs and mypy cannot see its types. By default it reports the gap and treats every name from that module as Any:

$ cat imp.py
import cowsay
reveal_type(cowsay.not_a_real_attribute)

$ mypy imp.py
imp.py:1: error: Skipping analyzing "cowsay": module is installed, but missing library stubs or py.typed marker  [import-untyped]
imp.py:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
imp.py:2: note: Revealed type is "Any"
Found 1 error in 1 file (checked 1 source file)

--ignore-missing-imports (or a per-module ignore_missing_imports override) silences the error and keeps the Any type. --follow-untyped-imports instead reads the module’s source and type-checks against it, so genuine errors that the Any fallback hid, such as touching an attribute that the module never defines, start surfacing.

Restrict Any with the disallow-any flags

Once annotations are in place, a family of flags controls where Any is still tolerated. The simplest is --warn-return-any, which catches an annotated function returning an Any even though the rest of the file looks typed:

$ cat warn.py
from typing import Any

def grab() -> Any:        # explicitly typed Any
    return 1

def caller() -> int:
    return grab()         # int return, but value is Any

$ mypy warn.py
Success: no issues found in 1 source file

$ mypy --warn-return-any warn.py
warn.py:7: error: Returning Any from function declared to return "int"  [no-any-return]
Found 1 error in 1 file (checked 1 source file)

The --disallow-any-* siblings reject Any in other positions: --disallow-any-explicit forbids the literal Any annotation, --disallow-any-generics forbids unparameterized generics like list instead of list[int], and --disallow-any-expr flags any expression whose inferred type is Any. Together they are mypy’s levers for stopping Any from spreading after the untyped-def flags have done their work.

How ty handles untyped code

ty is inference-based like Pyrefly, and checks unannotated function bodies by default. Its return-inference policy is configurable. The gradual guarantee says that making types less precise should not introduce new static errors. Adding a precise annotation can reveal a mismatch that a dynamic type previously hid.

ty checks bodies but never asks for annotations

The unannotated body below is analyzed, and the literal error inside it is caught:

$ cat body.py
def f():
    return 1 + "x"          # literal error in a fully unannotated body

$ ty check body.py
error[unsupported-operator]: Unsupported `+` operation
 --> body.py:2:12
Found 1 diagnostic

ty never reports a function for lacking annotations, though. A fully unannotated function with no internal error passes cleanly:

$ cat noann.py
def add(a, b):
    return a + b

$ ty check noann.py
All checks passed!

There is no --disallow-untyped-defs equivalent. To require annotations, ty’s documentation points to Ruff’s flake8-annotations (ANN) rules, which enforce their presence as a lint separate from type checking.

Unannotated returns default to Unknown

ty infers local types without annotations. Ordinary unannotated function returns default to Unknown, as do parameters with no known type:

$ cat reveals.py
from typing import reveal_type

def get_id():
    return 1
r = get_id()
reveal_type(r)
r.upper()                   # int has no .upper()

def greet(name: str, count):
    reveal_type(count)

$ ty check reveals.py
info[revealed-type]: Revealed type
6 | reveal_type(r)
  |             ^ `Unknown`
info[revealed-type]: Revealed type
10 |     reveal_type(count)
   |                 ^^^^^ `Unknown`
Found 2 diagnostics

The consequence shows on line 7, where r.upper() is silent. Because get_id() is unannotated, ty types its return as Unknown rather than inferring int, so it raises no objection to calling a string method on the result. This is the default return-inference policy. Configured Pyrefly infers int and reports an error. ty’s Unknown here permits operations in the same way as mypy’s Any.

Untyped imports are analyzed, not waved through

mypy reports [import-untyped] for an installed stubless package and exposes its members as Any, unless configured to follow its source. ty reads the module’s source by default. Importing the same stubless cowsay and touching an attribute that it does not define is caught out of the box:

$ cat imp.py
from typing import reveal_type
import cowsay
reveal_type(cowsay.not_a_real_attribute)

$ ty check imp.py
error[unresolved-attribute]: Module `cowsay` has no member `not_a_real_attribute`
 --> imp.py:3:13
info[revealed-type]: Revealed type
3 | reveal_type(cowsay.not_a_real_attribute)
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Unknown`
Found 2 diagnostics

ty issues no “missing stubs” warning. It analyzes whatever source it can resolve and falls back to Unknown only where it genuinely cannot infer a type.

Opt into return inference

Add this to pyproject.toml:

[tool.ty.analysis]
return-type-inference = "always"

Run ty check reveals.py again. The ordinary get_id() return now reveals as Literal[1], and r.upper() produces unresolved-attribute with exit 1. This option does not infer generator or async-function returns. The return-inference reference describes its limits.

ty has no body-skipping equivalent of mypy’s default or annotation-requirement equivalent of --disallow-untyped-defs. Rule levels and per-file overrides control which diagnostics appear.

How Pyrefly handles untyped code

With its configured default preset, Pyrefly analyzes unannotated function bodies and reports errors that mypy would skip. It is also the most configurable of the three on this axis, with three separate settings that control how far inference reaches. The catch is that with no config file at all, Pyrefly skips unannotated bodies, the opposite of what its inference-based reputation suggests.

The no-config default skips unannotated bodies

Run pyrefly check without an applicable Pyrefly configuration or a configuration it can migrate and Pyrefly applies its basic preset, which sets check-unannotated-defs = false. In that mode it skips unannotated bodies exactly like mypy:

$ cat body.py
def f():
    return 1 + "x"          # literal error inside a fully unannotated body

$ pyrefly check body.py
 INFO 0 errors
No `pyrefly.toml` found, using preset `basic`.
Run `pyrefly init` to continue setting up Pyrefly.

With an applicable Pyrefly config that does not select a reduced preset, the option default (check-unannotated-defs = true) takes over and the same file reports the error:

$ echo > pyrefly.toml          # an empty config is enough
$ pyrefly check body.py
ERROR `+` is not supported between `Literal[1]` and `Literal['x']` [unsupported-operation]
 --> body.py:2:12

Initializing a fresh project enables the default checking policy. Migrating an existing mypy configuration can instead select legacy; inspect the generated settings. The basic and legacy presets keep check-unannotated-defs = false; the default and strict presets set it to true.

Three settings control how far inference reaches

With a config in place, three options govern Pyrefly’s treatment of untyped code:

Option Default What it controls
check-unannotated-defs true Whether the bodies of fully unannotated functions are analyzed at all
infer-return-types "checked" Whether an analyzed function’s return propagates a real type or Any ("checked", "annotated", "never")
infer-with-first-use true Whether an empty container like [] takes its element type from later use

The effect is visible with reveal_type. Under the full-inference defaults, Pyrefly infers a concrete type for an unannotated return and a populated container, so misuse of either is caught:

$ cat reveals.py
from typing import reveal_type

def get_id():
    return 1
r = get_id()
reveal_type(r)
r.upper()                  # int has no .upper()

x = []
x.append(1)
reveal_type(x)

$ pyrefly check reveals.py     # check-unannotated-defs=true, infer-return-types="checked", infer-with-first-use=true
 INFO revealed type: Literal[1] [reveal-type]
ERROR Object of class `int` has no attribute `upper` [missing-attribute]
 --> reveals.py:7:1
 INFO revealed type: list[int] [reveal-type]
 INFO 1 error

The return of get_id() is inferred as Literal[1], so r is an int and r.upper() is rejected. The empty list x takes its element type from the later x.append(1), becoming list[int].

Set infer-return-types = "never" and infer-with-first-use = false and Pyrefly stops propagating those inferred types. The same code reveals Unknown, and the r.upper() misuse goes silent:

$ pyrefly check reveals.py     # infer-return-types="never", infer-with-first-use=false
 INFO revealed type: Unknown [reveal-type]
 INFO revealed type: list[Unknown] [reveal-type]
 INFO 0 errors

Unknown here is the same implicit-Any placeholder ty uses: operations on it are not checked.

Unannotated parameters stay Unknown

In this partially annotated example, the unannotated parameter is Unknown. Body checking alone does not constrain it:

$ cat partial.py
from typing import reveal_type

def greet(name: str, count):    # name annotated, count not
    reveal_type(count)

$ pyrefly check partial.py
 INFO revealed type: Unknown [reveal-type]

This matters for partially annotated functions: annotating the return or some parameters does not retroactively type the ones you left bare. To catch misuse of count, annotate it.

Approximate mypy’s unannotated-code policy

To skip unannotated bodies and use dynamic return types:

# pyrefly.toml
check-unannotated-defs = false
infer-return-types = "never"

To analyze bodies while keeping unannotated return types dynamic, like mypy --check-untyped-defs:

# pyrefly.toml
check-unannotated-defs = true
infer-return-types = "never"

pyrefly init writes a starting config for you and, when it finds a mypy configuration to migrate, emits the legacy preset as a migration starting point. It does not guarantee diagnostic parity with mypy. The older untyped-def-behavior option is deprecated; its "check-and-infer-return-type" value maps to check-unannotated-defs = true with infer-return-types = "checked".

When inference itself disagrees

The split so far is about whether a checker looks at untyped code. The checkers also disagree on what they infer once they do look, and the clearest case is the return of an unannotated function:

def get_id():
    return 1
r = get_id()
reveal_type(r)
r.upper()                  # int has no .upper()

Under its default preset, Pyrefly infers the concrete return type and reports the misuse. With default return inference, mypy and ty both stay silent: mypy types the return Any, ty types it Unknown, and neither objects to r.upper():

Checker Revealed type of r r.upper()
mypy Any silent
ty (default) Unknown silent
ty (return-type-inference = "always") Literal[1] error: no attribute upper
Pyrefly (default preset) Literal[1] error: int has no attribute upper

The practical effect: this annotation-free bug is caught by configured Pyrefly and by ty with return inference enabled, but missed under mypy’s and ty’s default return policies. Switching checkers can surface or hide bugs on identical, annotation-free code. Moving between them is a rule-tuning exercise, not a drop-in swap.

Map each behavior to its setting

The same untyped-code question gets a different answer and a different knob in each checker:

Behavior mypy ty Pyrefly
Check unannotated bodies? Off by default; --check-untyped-defs (or --strict) Always on, not configurable On under default (check-unannotated-defs); off under basic and legacy
Require annotations on defs? --disallow-untyped-defs (or --strict) No equivalent; use Ruff flake8-annotations implicit-any-parameter requires parameter annotations; unannotated-return requires returns (all enables both)
Return type of an unannotated call Any; --disallow-untyped-calls flags the call site Unknown by default; return-type-inference = "always" infers ordinary returns Inferred via infer-return-types ("never" falls back to Unknown)
Untyped third-party import (no stubs) Any plus an [import-untyped] error; --follow-untyped-imports to analyze the source Analyzes the source; Unknown where it cannot infer Analyzes the source; Unknown where it cannot infer
Empty-container element (x = [], then x.append(1)) list[int] from later use Allows later heterogeneous appends; annotate to constrain elements list[int] via infer-with-first-use (off gives list[Unknown])

Compare the active configuration as well as the checker name. Both mypy’s default policy and Pyrefly’s unconfigured basic preset skip unannotated bodies. ty and Pyrefly each let return inference change independently of body checking.

Last updated on