Classes → dataclass
class → @dataclass(slots=True)
class User: id: int name: str = "anon" email: str?from dataclasses import dataclass
@dataclass(slots=True)class User: id: int name: str = "anon" email: str | None = Noneslots=True— instances don’t carry a per-object__dict__; saves memory and catches typo’d attribute writes.from dataclasses import dataclassis auto-injected.- Fields without defaults come first; fields with defaults follow (Python’s rule).
frozen modifier
class Point frozen: x: float y: floatfrom dataclasses import dataclass
@dataclass(slots=True, frozen=True)class Point: x: float y: floatField reassignment is a hard error (tyc::frozen_assign); runtime raises FrozenInstanceError.
frozen + inheritance
The frozen modifier sits between the class name and the parenthesised base list:
class Square frozen(Shape): side: float@dataclass(slots=True, frozen=True)class Square(Shape): side: floatclass Square(Shape) frozen: (with the modifier after the base list) does not parse. The same rule applies to generics: class Stack[T] frozen:, class Stack[T] frozen(BaseStack):.
Mutable-default factory rewrite
Mutable-literal defaults ([], {}, set(), list(), dict()) on a class body are rewritten to dataclasses.field(default_factory=...) so each instance gets its own fresh container:
class Bucket: items: list[str] = [] tags: dict[str, int] = {}from dataclasses import dataclass, field
@dataclass(slots=True)class Bucket: items: list[str] = field(default_factory=list) tags: dict[str, int] = field(default_factory=dict)The rewrite applies to class and class frozen (the per-instance factories work with frozen=True’s object.__setattr__-driven __init__). It is not applied to model (Pydantic handles mutable defaults itself), interface (Protocol — no __init__), or class! (escape hatch — user owns the body verbatim). Since v0.9.0 the in-process VM (tyc run) also runs the factory per instance — before v0.9.0 the VM shared one container across every instance, diverging from the compiled path.
tyc::class_attr_shadows_slot correspondingly does not fire on mutable-literal defaults — those become per-instance fields, not class-level slot descriptors. The warning still fires on immutable literals (int = 3, str = "x") where the slot-descriptor pitfall applies; annotate those as ClassVar[T].
impl ... : merges into class body
class User: id: int name: str
impl User: def display(self) -> str: return f"{self.name} (#{self.id})"@dataclass(slots=True)class User: id: int name: str
def display(self) -> str: return f"{self.name} (#{self.id})"Multiple impl blocks (across files) all merge.
model → Pydantic
model ApiUser: id: int email: strfrom pydantic import BaseModel, ConfigDict
class ApiUser(BaseModel): model_config = ConfigDict(extra="forbid")
id: int email: strextra="forbid" is the default (Pydantic’s stock extra="ignore" is rejected by Typhon’s safety posture). The configurable [emit] model-extra knob is roadmapped.
plain class — no-decoration escape hatch
plain class Bag: items: list[str] label: str = "unsorted"class Bag: items: list[str] label: str = "unsorted"plain class:
- No
@dataclassdecorator. - No synthesised
__init__. The body emits verbatim — instances acquire attributes when user code assigns them, exactly like a hand-written Python class. - Reach for
plain classwhen a metaclass-driven library (Textual, Django ORM, SQLAlchemy declarative) owns the attribute layout.
class! — escape hatch with synthesised constructor
class! MyModel(nn.Module): layer: nn.Linear dropout: float
def forward(self, x): return self.layer(x)import torch.nn as nn
class MyModel(nn.Module): layer: nn.Linear dropout: float
def __init__(self, layer: nn.Linear, dropout: float) -> None: super().__init__() self.layer = layer self.dropout = dropout
def forward(self, x): return self.layer(x)class!:
- No
@dataclassdecorator. __init__is auto-synthesised — callssuper().__init__()first, then assigns annotated fields in source order. The compiler strips class-level defaults that would otherwise be evaluated twice (once at class-definition time as a shared class attribute, then again per-instance in__init__).- Hand-written
__init__is preserved verbatim for cases where the base class needs configuration arguments that aren’t 1:1 with declared fields.
Typed exceptions
A common class! pattern is a typed exception subclass — extending Exception with structured fields the handler can read:
class! HttpError(Exception): code: int message: str
def fetch(url: str) -> str: raise HttpError(code=404, message="not found")class HttpError(Exception): code: int message: str
def __init__(self, code: int, message: str) -> None: super().__init__() self.code = code self.message = message
def fetch(url: str) -> str: raise HttpError(code=404, message="not found")Since v0.9.0 the synthesised __init__ also runs under the in-process VM (tyc run); before v0.9.0 the VM didn’t construct user-class instances for class!-declared exception types, so except HttpError as e: print(e.code) raised AttributeError at runtime. Exception-type matching also walks the MRO under the VM since v0.9.0, so except HttpError matches subclasses.
Auto-skip for framework bases
A plain class Foo(Base): (no marker) whose base is Enum / IntEnum / StrEnum / Flag / IntFlag / ABC / ABCMeta is automatically emitted without @dataclass. The same applies to Protocol / TypedDict / NamedTuple (which were always skipped). Project-specific bases can be added via [emit] skip-decoration-bases. Auto-skip drops only the decorator; it does not synthesise an __init__ — escalate to class! if you need one.
Generic classes
class Box[T]: value: T
impl[T] Box[T]: def get(self) -> T: return self.valuefrom dataclasses import dataclass
@dataclass(slots=True)class Box[T]: value: T
def get(self) -> T: return self.valuePEP 695 generic syntax is preserved (Python 3.13+ default target).
Where next
class,model,plain class,class!reference — declaration forms.implandextendreference — method merging.- Class Escape Hatches —
class!patterns by framework. [emit] skip-decoration-bases— project-specific framework bases.