class, model, plain class, class!
Typhon has four keywords for declaring class-like types, each emitting a different shape of Python.
class — dataclass emission (default)
class User: id: int name: str = "anon" email: str?Emits @dataclass(slots=True). The constructor is generated; fields with defaults can be omitted at construction time.
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 = Nonefrozen modifier
class Point frozen: x: float y: floatEmits @dataclass(slots=True, frozen=True). Field reassignment is a hard error; rebuild a new instance instead.
Note: dataclass frozen=True blocks field reassignment only — nested mutable containers can still be mutated. Use tuple / frozenset inside the class for deep guarantees.
frozen + inheritance — ordering matters
When combining frozen with a base class, the modifier comes between the class name and the base list (not after the parenthesised base):
class Square frozen(Shape): # ✅ parses, lowers to @dataclass(slots=True, frozen=True) side: float
class Square(Shape) frozen: # ❌ does not parse side: floatThe same rule applies to generics — type parameters sit before frozen:
class Stack[T] frozen: # ✅ parses items: tuple[T, ...]Inheritance
class Animal: name: str
class Dog(Animal): breed: strStandard Python inheritance. The dataclass-MRO rule applies: fields without defaults must precede fields with defaults across the whole MRO.
Body restrictions
A class body may contain:
- Annotated field declarations (
name: Torname: T = default). - Class-level docstring.
lazy letbindings (lower to@cached_property).
It may not contain:
def __init__— generated.tyc::manual_init.- Other
defmethods — put them in animplblock instead. - Plain assignments without annotations (
name = "..."at class scope).
Mutable defaults are rewritten to per-instance factories
name: list[str] = [] (and the equivalent {} / set() / list() / dict() shapes) is not a Python pitfall in Typhon. The desugar pass rewrites it to dataclasses.field(default_factory=list), so each instance gets its own fresh container instead of sharing a single mutable literal:
class Bucket: items: list[str] = [] # ✅ each Bucket() gets its own list tags: dict[str, int] = {} # ✅ each Bucket() gets its own dict
let a: Bucket = Bucket()let b: Bucket = Bucket()a.items.append("x")print(b.items) # [] — not sharedSince v0.9.0 the VM also honours the factory (it previously executed the rewritten code path correctly under tyc build, but tyc run was sharing one container across every instance). The tyc::class_attr_shadows_slot warning 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 actually applies; annotate those as ClassVar[T].
model — Pydantic emission
model ApiUser: id: int email: str name: str = "anon"from pydantic import BaseModel, ConfigDict
class ApiUser(BaseModel): model_config = ConfigDict(extra="forbid")
id: int email: str name: str = "anon"Emits a Pydantic BaseModel with extra="forbid":
Validation
Pydantic validates at construction time:
ApiUser(id=1, email="a@b.com") # ✅ApiUser(id="oops", email="a@b.com") # ❌ ValidationError at runtimeApiUser(id=1, email="a@b.com", extra="x") # ❌ ValidationError — extra="forbid"The type checker catches the first two at compile time; the third is a runtime check Pydantic enforces because extra="forbid" is on.
When to use model vs class
class (dataclass) | model (Pydantic) |
|---|---|
| Internal types | Data crossing trust boundaries |
| Maximum performance | Runtime validation |
| No extra constraints | Field(min_length=1, ...) validators available |
plain class — no-decoration escape hatch
plain class Bag: items: list[str] label: str = "unsorted"Emits a bare class with no @dataclass decorator and no synthesised __init__. The body emits verbatim, so attributes only exist on instances when user code assigns them — exactly like a hand-written Python class.
plain class Bag: items: list[str] label: str = "unsorted"class Bag: items: list[str] label: str = "unsorted"When to use plain class
Reach for plain class when you want stock Python class semantics:
- Metaclass-driven libraries that set instance attributes dynamically: Textual app classes, Django ORM models with descriptor-based fields, SQLAlchemy declarative bases, custom metaclasses.
- Descriptors (
@property,__get__/__set__/__delete__) on class-level names where@dataclass(slots=True)would conflict. - Public-API base classes for downstream subclassers who expect a non-slotted, non-frozen, non-dataclass parent.
- Anything where
@dataclassis the wrong fit butclass!over-promises by auto-synthesising__init__.
The relationship to the other forms is best read as a 2x2 grid:
@dataclass injected? | __init__ synthesised? | |
|---|---|---|
class Foo: | yes | by @dataclass |
class! Foo(Base): | no | yes (calls super().__init__()) |
plain class Foo: | no | no (body emits verbatim) |
Auto-skip for known framework bases
A plain class Foo(Base): (no plain / class! marker) whose base is Enum, IntEnum, StrEnum, Flag, IntFlag, ABC, ABCMeta, Protocol, TypedDict, or NamedTuple is automatically emitted without @dataclass, so the most common framework-base cases work without needing any marker. Project-specific bases can be added via [emit] skip-decoration-bases in typhon.toml. Auto-skip drops the decorator but does not synthesise an __init__ — write the constructor yourself, or escalate to class! if you want one synthesised.
class! — framework escape hatch
class! MyModel(nn.Module): layer: nn.Linear dropout: float
def forward(self, x): return self.layer(x)Emits a bare class with auto-synthesised __init__ that calls super().__init__() first.
import torch.nn as nn
class! MyModel(nn.Module): layer: nn.Linear dropout: floatimport 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 = dropoutWhat class! changes
- No
@dataclassdecorator. The class is emitted verbatim with whatever bases it declares. __init__is auto-synthesised when the body declares nodef __init__and at least one base is present. The synthesised constructor callssuper().__init__(), then assigns every annotated field throughselfin source order.- Hand-written
__init__is preserved verbatim. Use this when the base class needs configuration arguments that aren’t 1:1 with declared fields.
Exception subclasses with fields (v0.9.0)
A common class! pattern is a typed exception: extending Exception with structured fields the handler can read. v0.9.0 wires the synthesised __init__ through the VM, so the same .ty source runs identically under tyc run and tyc build && python build/main.py:
class! HttpError(Exception): code: int message: str
def fetch(url: str) -> str: raise HttpError(code=404, message="not found")
def main() -> None: try: let body: str = fetch("/missing") except HttpError as e: print(e.code, e.message) # ✅ binds e to the user Instance; MRO walks ExceptionBefore v0.9.0 the VM didn’t run the synthesised __init__ and e.code raised AttributeError. Exception-type matching also walks the MRO since v0.9.0, so except HttpError matches subclasses too.
When to use class!
For classes that must extend a framework base with a non-trivial __init__:
torch.nn.Moduleenum.Enumtyping.NamedTupleunittest.TestCase- Django models
- SQLAlchemy declarative bases
See Class Escape Hatches for the patterns.
Generics
Each form supports PEP 695 generics:
class Box[T]: value: T
model PagedResult[T]: items: list[T] next_cursor: str?
class! GenericModule[T](nn.Module): weights: TensorConstructor signatures
For class and model:
- Fields without defaults are positional.
- Fields with defaults follow, positional-or-keyword.
- The constructor’s parameter names match the field names.
For class!:
- The synthesised constructor has the same shape as
class/model. - Hand-written
__init__is preserved verbatim — any signature you write is the signature.
Where next
- Classes and Models (tour) — teaching page with worked examples.
implandextend— attaching methods.- Pydantic Boundary Models —
modelpatterns for HTTP/CLI/file inputs. - Class Escape Hatches —
class!patterns by framework.