Skip to content

tyc migrate

tyc migrate PATH [--check] [--force]

Converts typed Python (.py) to Typhon (.ty) in one pass. Used to bootstrap a Typhon project from an existing typed Python codebase.

What it rewrites

  • Optional[T] / T | None → T? in annotation positions.

  • @dataclass decorators and their from dataclasses import dataclass are dropped (Typhon classes emit as dataclasses by default).

  • Module-level annotated assignments (x: int = 1) gain let (or mut if reassigned later in the same module).

  • Function-body plain assignments (user = find_user(1), total = 0) gain let on the first occurrence of each name per function. If the same name is reassigned anywhere else in the file (in this function or another), the first occurrence is promoted to mut instead. Subsequent assignments to a name already bound in the same scope are left bare (a correct re-binding). Class-body annotated assignments are left alone — those are field declarations, not locals.

    The reassignment flag is file-wide (a single set, not per-function), so a total = total + 1 accumulator in one function will also tag a one-shot total = 0 in an unrelated function as mut. That’s a deliberate over-approximation — mut of an unmutated binding still type-checks, whereas the inverse (let on a counter) would not. If the spurious mut bothers you, rename the unmutated local.

  • from typing import TypeVar plus T = TypeVar("T") scaffolding is dropped; generic functions become PEP 695 form.

v0.13.0 enhancements

The migrator now does more of the idiomatic-Typhon rewrite automatically:

  • Class-body methods are relocated into impl blocks. A class with methods is split into the bare field-declaration class plus a sibling impl ClassName: block holding the methods.
  • class X(Enum): is rewritten to the enum keyword. A plain Enum subclass becomes the first-class enum X: declaration form.
  • field(default_factory=...) is simplified to bare-literal sugar. field(default_factory=list) / field(default_factory=dict) collapse to the = [] / = {} mutable-default form Typhon recognises.
  • Imports those rewrites orphan are pruned — e.g. the now-unused from enum import Enum or from dataclasses import field is dropped.

Together these mean migrated output checks with zero errors and zero warnings.

Migrated output is intended to pass tyc check on first try.

What it never touches

Rewrites fire on code only. Text inside a string literal — a docstring’s prose, a doctest, an embedded SQL or HTML template — is copied through verbatim, whatever it happens to look like. A line of docstring that reads like a binding is still a line of docstring.

A method also travels into its impl block whole, however its signature or docstring is laid out: a parameter list wrapped over several lines, a docstring whose backslash continuation puts a line at column zero, a stack of decorators above it.

Where the frozen modifier lands

@dataclass(frozen=True) becomes Typhon’s frozen modifier, which binds to the class name and sits ahead of any base list:

class Vec frozen(Base): # not `class Vec(Base) frozen:`
x: int

Type parameters stay glued to the name too — class Stack[T] frozen(Base):.

Blocks a rewrite empties

When a rewrite deletes the only statement of a block — a Protocol import that interface made dead inside a if sys.version_info >= ...: guard, say — the header would be left with no suite. The migrator puts a pass under it so the output still parses.

How far it gets

As a scale check: 1,110 of the 1,114 .py files in a CPython 3.13 lib/ tree — the stdlib plus site-packages — migrate to source that parses as Typhon. The four that do not are heavily metaprogrammed (typing_extensions, pkg_resources, yaml.resolver). Parsing is not type-checking: the sequence below is still the way to port a real codebase.

Examples

Terminal window
tyc migrate src/app.py # writes src/app.ty alongside
tyc migrate --check src/app.py # preview to stdout (no writes)
tyc migrate --force src/ # re-migrate, overwriting existing .ty files

PATH is a .py file or a directory (migrated recursively). A directory is migrated whole, tests/ included; only directories nobody writes by hand are skipped — virtual environments (any directory holding a pyvenv.cfg, plus .venv, .tox, .nox), VCS metadata (.git, .hg, .svn, .bzr), caches (__pycache__, .mypy_cache, .pytest_cache, …), node_modules and build. If any target .ty already exists, tyc migrate refuses before writing anything and names the file; --force (-f) overwrites instead. A .ty that is a symlink is never written through, even with --force.

--check mode

--check is a preview flag: it prints the migrated source to stdout (one block per file, headed by # ── <path> ──) instead of writing .ty files to disk. It does not compare the output against the original, and it always exits 0 on a successful migration — there is no “no diffs needed” exit code today.

To confirm in CI that a .py file is already Typhon-compatible, pipe the preview into diff against an expected .ty (or run tyc check on the preview output via a tempfile). A native exit-1-on-changes mode is a deliberate follow-up.

What it can not do automatically

  • Rewrite try/except into Result[T, E]. That’s a design-level refactor, not a mechanical translation. Migrate the file first; then refactor errors incrementally.
  • Convert class X(BaseModel) into model X:. Pydantic-using classes are detected but not auto-converted; you do it manually because the field ordering and Pydantic-specific decorators (@validator, Field(...), etc.) need attention.
  • Subclasses of framework bases (torch.nn.Module, Enum, unittest.TestCase) are not detected as candidates for class!. After migration, convert them by hand.

The migrator is the boring 80%; the remaining 20% is design work.

  1. Audit the project. Identify modules that are obvious wins (pure value types, helpers without Pydantic / framework bases) vs ones that need design work.
  2. Migrate a leaf module first (tyc migrate src/utils.py). Verify with tyc check. The migrator now inserts let/mut on function-body locals, so the typical first run should be clean — any remaining diagnostics are usually design issues (try/except patterns, framework bases) rather than missing keywords.
  3. Migrate adjacent modules. Where you cross into framework bases, mark them class! (or leave them as .py for now).
  4. Convert Pydantic-using classes to model by hand when ready.
  5. Refactor try/except to Result[T, E] module-by-module as time allows.

You don’t have to migrate the whole project — .ty and .py interoperate freely. The dial moves at whatever speed is right for your team.

Mixed .ty / .py projects

Plain .py files in src/ are copied to build/ unchanged. They can import from emitted modules, and vice versa. See Project Layout for the conventions.

Where next