Class Errors
tyc::manual_init
Writing __init__ inside class / model:
class User: id: int
def __init__(self, id: int) -> None: # ❌ self.id = idFix: drop the method. The constructor is generated from the field annotations. Use field defaults, or a free function for “convenience constructors”. For framework bases that need a hand-written __init__, use class!.
tyc::frozen_assign
Writing a field on a frozen class:
class P frozen: x: float
let p = P(x=1.0)p.x = 2.0 # ❌Fix: construct a new instance.
let q = P(x=2.0)tyc::method_in_class_body
A def appearing in a class body (instead of an impl block). Surfaces as a warning by default — set [strictness] methods-in-class-body = "error" to break CI on this form, or "off" to suppress it during migration.
class User: id: int
def display(self) -> str: # ⚠️ wrong place return f"user {self.id}"Fix: move into an impl User: block at the same scope. Multiple impl blocks for one class are merged at desugar.
class User: id: int
impl User: def display(self) -> str: return f"user {self.id}"self is auto-injected by the desugar pass, so an impl method written as def display() -> str still type-checks and emits the correct Python.
tyc::duplicate_class
A class NAME: statement re-uses a name that has already been declared at the same scope:
class User: id: int
class User: # ❌ redeclaration name: strPython silently lets the second definition shadow the first; Typhon flags it so the user notices.
Fix: rename one of the declarations, or merge the second body into impl User: / extend User:.
class User: id: int name: strtyc::impl_unknown_class
An impl NAME: block targets a class that does not exist in the current module:
impl Account: # ❌ no class Account in scope def display(self) -> str: ...The methods would otherwise lower into a free-floating __typhon_impl_NAME pseudo-class that the merge pass silently drops, producing dead code.
Fix: declare class Account: first, or fix the name to match an existing class in this module. extend Account: (the alias for user-defined classes) reports the same diagnostic.
tyc::impl_forward_reference
An impl method that stays in its class body reads, in a decorator or a parameter default, a name bound only after the class. Those expressions run when the class statement runs — before the name exists — so importing the module raises NameError:
class Greeter: name: str
let DEFAULT_GREETING: str = "hello"
impl Greeter: def __call__(self, word: str = DEFAULT_GREETING) -> str: # ❌ return f"{word}, {self.name}"Most methods in this position are defined at their impl block instead and attached to the class there, so they just work. A special method (__call__, __eq__, …), a @property / @classmethod / @cached_property / @abstractmethod, a method using a private __name, a method a base class may define, or one on a class subclassed before the block has to stay in the class body; the diagnostic says which.
Fix: bind the name above the class, or default the parameter to None and resolve the name inside the method body.
tyc::missing_field_init
An instance constructed via X.__new__(X) / object.__new__(X) (bypassing the auto-generated __init__) escapes the function — returned, passed as an argument — without every required field having been assigned:
class ApiClient: api_key: str base_url: str
def make() -> ApiClient: let c: ApiClient = ApiClient.__new__(ApiClient) c.base_url = "https://api.example.com" return c # ❌ tyc::missing_field_init — instance escapes without all required # fields set; missing: api_keyWithout this audit the emitted Python would crash with AttributeError: 'ApiClient' object has no attribute 'api_key' the first time the missing field is read.
Fix: either assign every required field before the instance escapes, or — preferably — use the normal constructor, which is arity-checked at compile time by tyc::arg_count and flags the forgotten field at the call site rather than the escape site:
def make() -> ApiClient: let c: ApiClient = ApiClient(api_key="…", base_url="https://api.example.com") return cWhat’s tracked. Only two construction shapes engage the audit: <ClassName>.__new__(<ClassName>) and object.__new__(<ClassName>). Tracking is dropped (conservatively, to avoid false positives) when any of the following happen before the escape:
setattr(c, ...)— dynamic attribute assignment defeats static field tracking.c.method(...)— a method call may initialise fields internally.- The binding is reassigned to anything other than another bypass call.
- The binding’s enclosing scope is wrapped in
unsafe:— the user opted out of the static-type discipline.
Limitations. Only return statements and call arguments count as escapes; container-literal storage (return [c]) and outer-scope assignment aren’t tracked. The audit is intra-procedural. Subclass field requirements aren’t tracked separately.
tyc::extend_builtin
An extend declaration targets a Python built-in type (str, list, dict, …) that cannot be modified at runtime. Typhon handles this safely by extracting each method to a module-level free function and rewriting call sites whose receiver carries a matching static annotation; this diagnostic fires when the rewrite is impossible (e.g. unsupported builtin, conflict with a stdlib method).
extend dict: def __hash__(self) -> int: ... # ❌ cannot override builtin dunderFix: wrap the value in a user-defined class and put the method there, or expose a free function on the module.
tyc::class_attr_shadows_slot
A class body contains only annotated defaults with no methods or per-instance fields — it reads like a namespace of constants, but @dataclass(slots=True) makes each name a slot descriptor at runtime, so Klass.NAME evaluates to the descriptor object, not the literal 3.
class Limits: MAX_RETRIES: int = 3 # ⚠️ slot descriptor at runtimeFix: annotate as ClassVar[T] so the dataclass decorator excludes the field from __slots__:
from typing import ClassVar
class Limits: MAX_RETRIES: ClassVar[int] = 3Mutable-default carve-out (v0.9.0)
The warning no longer false-positives on classes whose only annotated defaults are mutable literals (list[str] = [], dict[str, int] = {}, set[int] = set(), list() / dict() calls). Those defaults are rewritten at desugar time into dataclasses.field(default_factory=...) calls, so each instance gets its own fresh container rather than sharing a single constant — the “looks like a constants namespace” heuristic no longer applies:
class Bucket: items: list[str] = [] # ✅ no warning since v0.9.0 — per-instance factory tags: dict[str, int] = {} # ✅The warning still fires on immutable literals (int = 3, str = "x") where the slot-descriptor pitfall genuinely applies.
tyc::duplicate_method
Two impl / extend blocks for the same class define the same method. The
blocks are merged into one class body, where the second definition would
silently replace the first.
class Box: value: int
impl Box: def get(self) -> int: return self.value
extend Box: def get(self) -> int: # ❌ method `get` is defined more than once on `Box` return self.value * 2Fix: rename one (get_doubled), delete the copy, or merge the blocks.
tyc::field_default_ordering
A field without a default follows a field with one. The generated
__init__ follows declaration order, and Python refuses a non-default
parameter after a default one — the class would fail with TypeError at
import.
class Worker: name: str retries: int = 3 queue_size: int # ❌ (no default) is declared after `retries` (has a default)Fix: move every field without a default above the defaulted ones.
model classes are exempt (Pydantic orders its own fields).
tyc::frozen_inheritance_conflict
A frozen class and its in-module dataclass base disagree on frozen-ness.
CPython raises TypeError: cannot inherit frozen dataclass from a non-frozen one (or the reverse) when the module is imported.
class Shape: name: str
class Square frozen(Shape): # ❌ frozen class, non-frozen base side: floatFix: make both frozen (class Shape frozen:) or neither. Bases from
other modules or non-dataclass bases are not compared.
tyc::newtype_violation
A newtype constructor receives a value of the wrong base type.
newtype UserId = int
def main() -> None: let bad: UserId = UserId("seven") # ❌ expected `UserId`, found bare `str` print(bad)Fix: pass a value of the base type: UserId(7). Passing a bare int
where a UserId is expected, or one newtype where another is expected, is
reported as tyc::type_mismatch.
tyc::newtype_invalid_base
A newtype declares a literal, not a type, as its base.
newtype Color = "red" # ❌ `newtype Color` base must be a type, not `string literal`Fix: wrap a type (newtype UserId = int). For a fixed set of strings,
use a literal union (type Color = "red" | "green" | "blue") or an enum.
tyc::self_outside_impl
self is read in a function that has no self — anywhere outside an
impl method body.
def area() -> float: return self.width * self.height # ❌ cannot find 'self' in scopeFix: move the function into an impl block of the class it belongs to:
class Rect: width: float height: float
impl Rect: def area(self) -> float: return self.width * self.heighttyc::not_a_context_manager
The subject of a with (or async with) is a class from this module that
lacks __enter__ / __exit__ (or __aenter__ / __aexit__). CPython raises
TypeError at the with statement.
class Plain: label: str
def main() -> None: with Plain(label="world") as p: # ❌ `Plain` is not a context manager: it has no `__enter__` method print(p.label)Fix: implement the protocol in an impl block, or use a
@contextmanager factory. Classes with a base from another module, and
values of unknown type, are not checked.
tyc::raise_non_exception
A raise operand is provably not an exception: a literal, another
primitive, or an instance of a class that does not derive from
BaseException. CPython raises TypeError: exceptions must derive from BaseException.
def f() -> int: raise 42 # ❌ cannot raise `int`Fix: raise an exception (raise ValueError("bad input")), or return an
Err(...) for an error the caller should handle.
Where next
- Classes and Models (tour) — teaching page.
class,model,class!reference — declaration forms.implandextendreference — method attachment.[strictness] methods-in-class-body— severity knob for Rule 4.