# 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](https://pydevtools.com/handbook/reference/mypy.md) cleanly can light up with errors the first time you run [ty](https://pydevtools.com/handbook/reference/ty.md) or [Pyrefly](https://pydevtools.com/handbook/reference/pyrefly.md), without a single annotation having changed.

## Two models for untyped code

Type checkers split into two camps on the question of unannotated code.

**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](https://peps.python.org/pep-0483/) working as designed. You add annotations where you want static guarantees and the rest of the code runs untouched.

**Inference-based** checkers infer types for everything, annotated or not, and check it all. ty and Pyrefly take this path, as do pyright and most of the newer checkers. They read an unannotated function, work out what its locals and return value must be, and report errors in code that mypy would skip.

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

```console
$ 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:

```console
$ 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.

```console
$ 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:

```console
$ 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](https://pydevtools.com/handbook/how-to/how-to-configure-mypy-strict-mode.md) lists the full set of flags `--strict` enables.

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

```console
$ 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`:

```console
$ 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`

Import a package that ships no type information (no inline annotations and no `py.typed` marker) and mypy cannot see its types. By default it reports the gap and treats every name from that module as `Any`:

```console
$ cat imp.py
import cowsay
reveal_type(cowsay.cow)

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

```console
$ 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, so it checks unannotated function bodies by default. One principle governs the rest of its behavior: the gradual guarantee, which holds that adding or removing annotations on working code should never introduce new errors. ty honors it by inferring `Unknown`, an implicit form of `Any`, wherever a concrete type is missing, and then declining to complain about what happens to it.

### ty checks bodies but never asks for annotations

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

```console
$ 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:

```console
$ 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](https://pydevtools.com/handbook/reference/ruff.md)'s `flake8-annotations` (`ANN`) rules, which enforce their presence as a lint separate from type checking.

### Unannotated symbols are `Unknown`

When ty cannot read a type from an annotation, it infers `Unknown`. The return of an unannotated function and an unannotated parameter are both `Unknown`:

```console
$ 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 gradual guarantee at work: ty will not flag code that has no annotations to check against, which is the exact spot where Pyrefly (inferring `int`) reports an error. ty's `Unknown` here plays the same role as mypy's `Any`.

### Untyped imports are analyzed, not waved through

mypy treats a stubless package as `Any` and stays silent until you pass `--follow-untyped-imports`. 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:

```console
$ cat imp.py
from typing import reveal_type
import cowsay
reveal_type(cowsay.cow)

$ ty check imp.py
error[unresolved-attribute]: Module `cowsay` has no member `cow`
 --> imp.py:3:13
info[revealed-type]: Revealed type
3 | reveal_type(cowsay.cow)
  |             ^^^^^^^^^^ `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.

### Nothing here is configurable

ty exposes no setting that changes how it treats untyped code. There is no `check_untyped_defs` to flip and no preset to choose. The `[tool.ty.rules]` table adjusts the severity of individual diagnostics, turning a rule into a warning or silencing it, but it cannot make ty skip unannotated bodies or demand annotations. The gradual guarantee is the design, not a default you opt into.

## How Pyrefly handles untyped code

Pyrefly is an inference-based checker: by default it 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` in a project with no `pyrefly.toml` and Pyrefly applies its `basic` preset, which sets `check-unannotated-defs = false`. In that mode it skips unannotated bodies exactly like mypy:

```console
$ 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.
```

The moment a config file exists, the option default (`check-unannotated-defs = true`) takes over and the same file reports the error:

```console
$ 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
```

Running `pyrefly init` is what flips the project off the `basic` preset and onto full inference, which is why teams see Pyrefly "suddenly find errors" right after initializing. 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:

```console
$ 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:

```console
$ 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`

No setting makes Pyrefly infer a parameter type from the function body. Wherever Pyrefly analyzes a function, an unannotated parameter is `Unknown`:

```console
$ 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.

### Make Pyrefly match mypy

To reproduce mypy's default behavior (skip unannotated bodies, treat unannotated returns as `Any`):

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

To match `mypy --check-untyped-defs` (analyze the bodies, but still treat unannotated returns as `Any`):

```toml
# 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 so a previously mypy-clean codebase does not flood you with new errors on the first run. 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:

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

Pyrefly infers the concrete return type and reports the misuse. 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 | `Unknown` | silent |
| Pyrefly | `Literal[1]` | error: `int` has no attribute `upper` |

The practical effect: the same annotation-free bug is caught by Pyrefly and missed by mypy and ty. 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 once a config file exists (`check-unannotated-defs`); off under the no-config `basic` preset |
| Require annotations on defs? | `--disallow-untyped-defs` (or `--strict`) | No equivalent; use Ruff `flake8-annotations` | No equivalent; use Ruff `flake8-annotations` |
| Return type of an unannotated call | `Any`; `--disallow-untyped-calls` flags the call site | `Unknown` | Inferred via `infer-return-types` (`"never"` falls back to `Unknown`) |
| Untyped third-party import (no stubs) | `Any` plus an `[import-untyped]` warning; `--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 = []`) | `list[int]` from later use | `list[int]` from later use | `list[int]` via `infer-with-first-use` (off gives `list[Unknown]`) |

Two patterns fall out of the table. mypy is the only checker that skips unannotated bodies by default and the only one that treats a stubless import as opaque `Any` rather than reading its source. ty is the only checker with no knobs at all, because the gradual guarantee fixes its behavior by design.

## Learn More

- [How do Python type checkers compare?](https://pydevtools.com/handbook/explanation/how-do-mypy-pyright-and-ty-compare.md)
- [How to configure mypy strict mode](https://pydevtools.com/handbook/how-to/how-to-configure-mypy-strict-mode.md)
- [How to gradually adopt type checking in an existing Python project](https://pydevtools.com/handbook/how-to/how-to-gradually-adopt-type-checking-in-an-existing-python-project.md)
- [Pyrefly](https://pydevtools.com/handbook/reference/pyrefly.md) reference
- [Pyrefly configuration docs](https://pyrefly.org/en/docs/configuration/)
- [Migrating from mypy](https://pyrefly.org/en/docs/migrating-from-mypy/) (Pyrefly docs)
