Migrating From Mypy to Ty

Read as Markdown

I recently migrated litecli, a Python command-line interface for SQLite databases, from mypy to ty. Here’s what I learned during the process.

About the Project

litecli

Litecli is a relatively small codebase with approximately 7,000 lines of Python code. It already had type hints and was type-checked with mypy before the migration.

What is ty? Ty is an extremely fast Python type checker and language server written in Rust by Astral, the team behind uv and Ruff. It’s designed as a modern alternative to mypy, Pyright, and Pylance. ty comes with a LSP server and editor integrations.

Migration Approach

My migration strategy was straightforward:

  1. Add ty to the project dependencies and run the type checker
  2. Fix type errors by adjusting type annotations
  3. Use # ty: ignore[rule] comments sparingly—only for exceptional cases where types are unavailable or features aren’t yet supported by ty
  4. Remove mypy.

The final pull request of the migration.

Getting Started with Ty

I began by experimenting with ty’s playground, which provides a browser-based interface for testing Python snippets across different Python versions (similar to the mypy playground).

Here’s an interesting example that highlights behavioral differences between the two type checkers:

from typing import Callable, cast

def bg_refresh(
    callbacks: Callable | list[Callable],
    completer_options: dict,
) -> None:
    if callable(callbacks):
        callbacks_list: list[Callable] = [callbacks]
    else:
        callbacks_list = list(callbacks)

Mypy playground reports no errors, but ty playground identifies an issue:

Argument to bound method `__init__` is incorrect: Expected `Iterable[Unknown]`, 
found `(((...) -> Unknown) & ~(() -> object)) | (list[(...) -> Unknown] & ~(() -> object))` 
(invalid-argument-type) [Ln 10, Col 35]

Ty’s error messages include the rule name (invalid-argument-type), and the documentation provides detailed explanations of each rule.

Configuration

Ty supports configuration via pyproject.toml. Here’s a comparison of mypy and ty configurations for litecli:

Mypy configuration:

[tool.mypy]
pretty = true
strict_equality = true
ignore_missing_imports = true
warn_unreachable = true
warn_redundant_casts = true
warn_no_return = true
warn_unused_configs = true
show_column_numbers = true
show_error_codes = true
warn_unused_ignores = true
python_version = "3.9"
explicit_package_bases = true
packages = ["litecli"]
disallow_incomplete_defs = true
disallow_untyped_defs = true
no_implicit_optional = true
warn_return_any = true
exclude = [
    '^build/',
    '^dist/',
    '^\.tox/',
    '^\.venv/',
    '^venv/',
    '^\.mypy_cache/',
    '^\.pytest_cache/',
    '^\.ruff_cache/'
]

Ty configuration:

[tool.ty.environment]
python-version = "3.9"
root = [".", "litecli", "litecli/packages", "litecli/packages/special"]

[tool.ty.src]
exclude = [
    '**/build/',
    '**/dist/',
    '**/.tox/',
    '**/.venv/',
    '**/.mypy_cache/',
    '**/.pytest_cache/',
    '**/.ruff_cache/'
]

I kept the ty configuration minimal because many mypy options don’t have direct equivalents in ty. The error messages include rule names, error codes, line numbers, and violation types by default, making many of mypy’s display options unnecessary.

Here’s an example of ty’s detailed error output:

$ uv run ty check -v

error[unresolved-attribute]: Module `signal` has no member `CTRL_C_EVENT`
  --> tests/utils.py:81:22
   |
79 |     system_name = platform.system()
80 |     if system_name == "Windows":
81 |         os.kill(pid, signal.CTRL_C_EVENT)
   |                      ^^^^^^^^^^^^^^^^^^^
82 |     else:
83 |         os.kill(pid, signal.SIGINT)
   |
info: The member may be available on other Python versions or platforms
info: Python 3.9 was assumed when resolving the `CTRL_C_EVENT` attribute
  --> pyproject.toml:96:18
   |
95 | [tool.ty.environment]
96 | python-version = "3.9"
   |                  ^^^^^ Python version configuration
97 | root = ["litecli"]
   |
info: rule `unresolved-attribute` is enabled by default

Note: Configuring the project root took some trial and error, as the documentation lacked clear examples. I found the solution by examining other projects’ configurations on GitHub. Definitely a scope for improvement!

Key Differences and Observations

1. Opinionated By Design

Ty enforces standard Python behavior and doesn’t allow workarounds that mypy permits. For example:

from __future__ import annotations

def list(self) -> list[str]:
    section = cast(dict[str, str], self.config.get(self.section_name, {}))
    return list(section.keys())

Ty reports: Invalid subscript of object of type 'def list(self) -> Unknown' in type expression (invalid-type-form) [Ln 42, Col 23]

GitHub discussion. An another example github discussion.

2. Runtime Behavior Alignment

Ty stays closer to Python’s runtime behavior and catches errors that mypy misses:

import http

# Correct and safe
http.__file__

# Incorrect - mypy doesn't complain, ty does
from http import __file__

Ty error: Cannot shadow implicit global attribute '__file__' with declaration of type 'str | None' (invalid-declaration) [Ln 5, Col 18]

Ty playground | Mypy playground

3. Strict by Default

Ty operates in strict mode by default, similar to mypy’s --strict flag. For example:

from typing import Callable

def bg_refresh(
    callbacks: Callable | list[Callable],
    completer_options: dict,
) -> None:
    if callable(callbacks):
        callbacks_list: list[Callable] = [callbacks]
    else:
        callbacks_list = list(callbacks)

ty error message

Argument to bound method `__init__` is incorrect: Expected `Iterable[Unknown]`, found `(((...) -> Unknown) & ~(() -> object)) | (list[(...) -> Unknown] & ~(() -> object))` (invalid-argument-type) [Ln 12, Col 35]

mypy error message in strict mode and no error in non-strict mode

main.py:5: error: Missing type parameters for generic type "Callable"  [type-arg]
main.py:6: error: Missing type parameters for generic type "dict"  [type-arg]
main.py:9: error: Missing type parameters for generic type "Callable"  [type-arg]
Found 3 errors in 1 file (checked 1 source file)

4. No File-Level Ignore Comments

Unlike mypy’s # mypy: ignore-errors to suppress all errors in a file, ty doesn’t provide an equivalent. You must address each error individually.

5. Performance

For litecli’s 7,000 lines of code, type checking time on OSX from the command line.

Ty is 7.3× faster—a significant advantage. Note: This is not proper benchmarking just an indication.

6. Using LLMs for Migration

I avoided using large language models to migrate the entire codebase initially, wanting to understand ty’s behavior firsthand. However, once familiar with ty, I used amp to review changes and suggest improvements. LLMs proved helpful for converting ty’s format: # ty: ignore[rule-name] to geenric type ignore statement # type: ignore and fix ty specific errors.

7. Other Observations

Most remaining issues involved missing types, reliance on runtime behavior, and missing guard checks—all areas where ty’s stricter checking proved beneficial.

Conclusion

Ty brings several advantages over mypy:

However, ty’s error messages is cryptic sometimes and hard to follow. A human readable error message would improve the developer experience. Here is an example ty error,

Argument to bound method `__init__` is incorrect: Expected `Iterable[Unknown]`, found `(((...) -> Unknown) & ~(() -> object)) | (list[(...) -> Unknown] & ~(() -> object))` (invalid-argument-type) [Ln 12, Col 35]` and my py error: `main.py:5: error: Missing type parameters for generic type "Callable" 

The ty team deserves recognition for their responsiveness to issues and continuous documentation improvements. Even when I filed duplicate issues(one, two, three, four), they promptly replied with relevant discussions and context.