Skip to content

Class Errors

tyc::manual_init

Writing __init__ inside class / model:

class User:
id: int
def __init__(self, id: int) -> None: # ❌
self.id = id

Fix: 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: str

Python 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: str

tyc::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_key

Without 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 c

What’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 dunder

Fix: 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 runtime

Fix: annotate as ClassVar[T] so the dataclass decorator excludes the field from __slots__:

from typing import ClassVar
class Limits:
MAX_RETRIES: ClassVar[int] = 3

Mutable-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 * 2

Fix: 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: float

Fix: 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 scope

Fix: 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.height

tyc::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