Migrating Litecli 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 implementation details are available in the pull request.

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 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 includes the rule name (invalid-argument-type) in its error output, and the documentation provides detailed explanations for 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 required some trial and error, as the documentation lacked clear examples.

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]

See related discussions: GitHub issue #2035 and #2038.

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:

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 (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)

Mypy in non-strict mode produces no errors for the same code, while ty flags the issue consistently.

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 with rule-specific # ty: ignore[rule-name] comments.

5. Performance

For litecli’s 7,000 lines of code, type checking times on macOS from the command line were:

Ty is approximately 7.3× faster. Note: This is a rough measurement, not a rigorous benchmark, but it indicates a significant performance advantage.

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’s patterns, I used Amp to review changes and suggest improvements. LLMs proved helpful for converting ty-specific ignore syntax (# ty: ignore[rule-name]) to generic type ignore statements (# type: ignore) and fixing ty-specific errors.

Limitations and Trade-offs

While ty offers significant advantages, there are tradeoffs to consider:

Error message clarity: Ty’s type union representations in error messages can be verbose and difficult to parse. For example, the invalid-argument-type error shown above uses complex notation that requires cross-referencing documentation to understand fully. More human-readable error messages would improve the developer experience.

Missing features: As a newer tool, ty doesn’t yet support all use cases mypy handles. You may encounter unsupported typing constructs or need to adjust code patterns that worked with mypy.

Learning curve: The opinionated strictness requires understanding ty’s philosophy. Code that passes mypy may need refactoring to satisfy ty’s requirements.

Conclusion

Ty brings substantial advantages for type checking Python codebases: