Skip to content

Set up type checking for a Python project with ty

A type checker reads your code without running it and reports the places where the types do not line up. This tutorial adds ty to a uv project, plants three bugs that a test suite would miss, reads what ty says about each one, and tunes which rules ty enforces.

The handbook recommends Pyrefly as the default type checker for new projects, because ty is still in beta. Choose ty when you want one toolchain across linting, packaging, and type checking, or when you value its gradual guarantee that annotating working code never introduces new errors. ty vs Pyrefly covers the tradeoff.

Prerequisites

Install uv on your system. You do not need Python installed separately; uv fetches an interpreter for you.

Create a project to check

$ uv init --no-package ty-demo
Initialized project `ty-demo` at `/path/to/ty-demo`
$ cd ty-demo

If you see error: command not found: uv, finish the installation guide and reopen your shell.

--no-package asks for a flat layout with main.py at the project root; a plain uv init scaffolds a src/ package instead. Either works with ty, and the flat layout keeps the file paths in the output short.

Write code that hides three bugs

Replace the contents of main.py with a small user lookup:

main.py
def find_user(users: list[dict[str, str]], name: str) -> dict[str, str] | None:
    for user in users:
        if user["name"] == name:
            return user
    return None


def greet(name: str) -> str:
    return f"Hello, {name}!"


def count_label(users: list[dict[str, str]]) -> str:
    return len(users)


people = [{"name": "Ada", "email": "ada@example.com"}]

found = find_user(people, "Ada")
print(greet(found["name"]))
print(greet(len(people)))
print(count_label(people))

Every function here carries type annotations, and that is what gives ty something to check. The annotation dict[str, str] | None on find_user is a promise: callers might get a dictionary back, or they might get None.

Run it:

$ uv run main.py
Hello, Ada!
Hello, 1!
1

The program prints three lines with no traceback and exits 0. A test suite that asserts on this output would pass.

The file still holds three defects. find_user can return None, and the next line subscripts the result without checking. count_label declares that it returns a str and returns an int. greet declares a str parameter and gets handed an int.

None of the three fire today, because Ada happens to be in the list and an f-string formats an int as happily as a str. They fire on the day someone looks up a name that is missing, or calls .upper() on what count_label returned.

Add ty as a development dependency

$ uv add --dev ty
Using CPython 3.14.6
Creating virtual environment at: .venv
Resolved 2 packages in 13ms
Installed 1 package in 5ms
 + ty==0.0.65

Your Python and ty versions may differ. The new .venv/ directory is the project’s virtual environment, created because this is the first dependency. uv records ty under [dependency-groups] in pyproject.toml as a minimum version and pins the resolved version in uv.lock, so a collaborator who runs uv sync gets the same checker you have.

To evaluate ty on a codebase without committing to it, run uvx ty check instead. That checks the project without adding anything to your dependencies.

Run your first check

$ uv run ty check
error[invalid-return-type]: Return type does not match returned value
  --> main.py:12:49
   |
12 | def count_label(users: list[dict[str, str]]) -> str:
   |                                                 --- Expected `str` because of return type
13 |     return len(users)
   |            ^^^^^^^^^^ expected `str`, found `int`
   |

error[not-subscriptable]: Cannot subscript object of type `None` with no `__getitem__` method
  --> main.py:19:13
   |
19 | print(greet(found["name"]))
   |             ^^^^^^^^^^^^^
   |

error[invalid-argument-type]: Argument to function `greet` is incorrect
  --> main.py:20:13
   |
20 | print(greet(len(people)))
   |             ^^^^^^^^^^^ Expected `str`, found `int`
   |
info: Function defined here
 --> main.py:8:5
  |
8 | def greet(name: str) -> str:
  |     ^^^^^ --------- Parameter declared here
  |

Found 3 diagnostics

ty found three defects in a program that exited 0 a moment ago, by reading annotations alone.

Read one diagnostic closely

Take the last one apart, because every ty diagnostic follows the same shape:

  • error is the severity. Rules can also report at warn level, which you will configure later in this tutorial.
  • [invalid-argument-type] is the rule name. This is the string you use to look the rule up or to suppress it.
  • --> main.py:20:13 is the file, line, and column.
  • The excerpt with carets marks the exact expression at fault, and the trailing note names both types.
  • The info: block points at the other end of the mismatch: the greet signature that declared the expectation.

Two locations per diagnostic is the part worth internalizing. A type error is a disagreement between a declaration and a use, and ty shows you both sides so you can decide which one is wrong.

Honor the return type you declared

Start at the top of the output. count_label promises a str and hands back whatever len() returns, which is an int. The declaration is the useful half here, so change the body to match:

main.py
def count_label(users: list[dict[str, str]]) -> str:
    return str(len(users))
$ uv run ty check
error[not-subscriptable]: Cannot subscript object of type `None` with no `__getitem__` method
  --> main.py:19:13
   |
19 | print(greet(found["name"]))
   |             ^^^^^^^^^^^^^

error[invalid-argument-type]: Argument to function `greet` is incorrect
  --> main.py:20:13
   |
20 | print(greet(len(people)))
   |             ^^^^^^^^^^^ Expected `str`, found `int`
info: Function defined here
 --> main.py:8:5
  |
8 | def greet(name: str) -> str:
  |     ^^^^^ --------- Parameter declared here

Found 2 diagnostics

Rule out None

The next error says found might be None, and None cannot be subscripted. find_user returns None when no match exists, and nothing between the call and the subscript rules that out.

Handle the missing case explicitly:

main.py
found = find_user(people, "Ada")
if found is None:
    raise SystemExit("No user named Ada")

print(greet(found["name"]))
$ uv run ty check
error[invalid-argument-type]: Argument to function `greet` is incorrect
  --> main.py:23:13
   |
23 | print(greet(len(people)))
   |             ^^^^^^^^^^^ Expected `str`, found `int`
info: Function defined here
 --> main.py:8:5
  |
8 | def greet(name: str) -> str:
  |     ^^^^^ --------- Parameter declared here

Found 1 diagnostic

The subscript on the following line is untouched, yet its error is gone. The if found is None guard proved to ty that None is impossible past that point, so ty narrowed the type of found from dict[str, str] | None to dict[str, str] for the rest of the block. This is type narrowing, and it is why a guard clears errors on lines you never edited.

Pass the type the function asks for

len(people) is an int, and greet declares name: str. You already have a function that turns the count into a string, so use it:

main.py
print(greet(count_label(people)))
$ uv run ty check
All checks passed!

Run the program to confirm the output is unchanged:

$ uv run main.py
Hello, Ada!
Hello, 1!
1

Same three lines, and now the annotations are telling the truth about the code.

Look up a rule you do not recognize

ty explain rule prints the definition of any rule name you see in the output:

$ uv run ty explain rule unresolved-attribute
# unresolved-attribute

Default level: error | Stable (since 0.0.1-alpha.1)

## What it does

Checks for unresolved attributes.

## Why is this bad?

Accessing an unbound attribute will raise an `AttributeError` at runtime.
An unresolved attribute is not guaranteed to exist from the type alone,
so this could also indicate that the object is not of the type that the user expects.

... (a short annotated code example follows)

The Default level line tells you what you would be changing if you configured this rule, and the stability marker tells you how settled the rule is. Run ty explain rule --help to see how to dump every rule at once, or browse the rules reference.

Silence a diagnostic ty gets wrong

Static analysis cannot follow every dynamic trick, so sometimes ty flags code that is correct. Create settings.py with an attribute assigned through setattr:

settings.py
class Settings:
    pass


settings = Settings()
setattr(settings, "debug", True)

print(settings.debug)
$ uv run ty check
error[unresolved-attribute]: Object of type `Settings` has no attribute `debug`
 --> settings.py:8:7
  |
8 | print(settings.debug)
  |       ^^^^^^^^^^^^^^

Found 1 diagnostic

ty is right that Settings declares no debug attribute and wrong that this breaks at runtime. Suppress this one line with a comment that names the rule:

settings.py
print(settings.debug)  # ty: ignore[unresolved-attribute]
$ uv run ty check
All checks passed!

A bare # ty: ignore with no rule name also works, but it hides every diagnostic on that line, including future ones, so always name the rule you mean. Either form can also sit on its own line directly above the code, which helps when the offending line is already long.

Suppressions rot, so ty tracks them. Declare the attribute properly and the suppression has nothing left to hide:

settings.py
class Settings:
    debug: bool = False


settings = Settings()
settings.debug = True

print(settings.debug)  # ty: ignore[unresolved-attribute]
$ uv run ty check
warning[unused-ignore-comment]: Unused `ty: ignore` directive
 --> settings.py:8:24
  |
8 | print(settings.debug)  # ty: ignore[unresolved-attribute]
  |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
help: Remove the unused suppression comment
  |
7 |
  - print(settings.debug)  # ty: ignore[unresolved-attribute]
8 + print(settings.debug)
  |

A suppression that outlives its error becomes a diagnostic of its own, so the comments you leave behind do not quietly accumulate.

Change a rule’s level in pyproject.toml

Per-line comments suit one-off exceptions. When a whole rule is more noise than signal for your project, set its level in pyproject.toml. Every rule accepts error, warn, or ignore.

Put settings.py back to the setattr version and drop the suppression comment, so there is a live diagnostic to work with. Then add:

pyproject.toml
[tool.ty.rules]
unresolved-attribute = "warn"
$ uv run ty check
warning[unresolved-attribute]: Object of type `Settings` has no attribute `debug`
 --> settings.py:8:7
  |
8 | print(settings.debug)
  |       ^^^^^^^^^^^^^^

Found 1 diagnostic

The severity changed from error to warning, and the diagnostic still prints.

Downgrading a rule to warn does not make the check pass. ty check exits with status 1 whenever it finds any diagnostic, warnings included, so a pre-commit hook or CI step still fails. Pass --exit-zero-on-warning when you want warnings to be advisory and only errors to fail the build.

Setting the rule to ignore removes the diagnostic entirely:

pyproject.toml
[tool.ty.rules]
unresolved-attribute = "ignore"
$ uv run ty check
All checks passed!

Relax a rule for one file

Turning off unresolved-attribute everywhere costs you the check in the code that needs it most. Scope the exception to the file that earned it instead.

Delete the [tool.ty.rules] table you just added and replace it with an overrides entry:

pyproject.toml
[[tool.ty.overrides]]
include = ["settings.py"]

[tool.ty.overrides.rules]
unresolved-attribute = "ignore"

Removing the global table matters. A project-wide unresolved-attribute = "ignore" silences the rule everywhere, and leaving it in place next to the override means main.py keeps the rule turned off no matter what the override says.

$ uv run ty check
All checks passed!

main.py still gets the full rule set; only settings.py is exempt. include accepts glob patterns, which makes this the right tool for relaxing rules across a directory such as generated code or a test suite. The configuration reference lists every setting these tables accept.

Keep the check running

You now have the loop: write annotated code, run uv run ty check, then fix what is real and configure what is not. Two things make it stick.

Run ty in your editor rather than only in the terminal. ty ships a language server that reports the same diagnostics as you type, and How to try the ty type checker covers the setup for VS Code, Zed, Neovim, and PyCharm.

Run ty in continuous integration so a mismatch cannot reach the default branch. How to use ty in CI has the GitHub Actions job.

Learn More

Last updated on