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.
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:
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:
erroris the severity. Rules can also report atwarnlevel, 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:13is 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: thegreetsignature 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:
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:
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:
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:
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:
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:
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:
[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.
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:
[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:
[[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
- ty: A Complete Guide goes deeper on ty’s type system, the gradual guarantee, and its behavior on unannotated code
- How do mypy, pyright, and ty compare? sets ty against the alternatives on speed, conformance, and maturity
- How to gradually adopt type checking in an existing Python project handles the case this tutorial skipped: a large codebase that reports hundreds of diagnostics on day one
- Set up Ruff for formatting and checking your code adds the linting and formatting half of the same toolchain
- ty documentation is the official reference
- ty playground checks snippets in the browser, which is handy for sharing a reproduction