Skip to content

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?

frozen modifier

class Point frozen:
x: float
y: float

Emits @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: float

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

Standard 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: T or name: T = default).
  • Class-level docstring.
  • lazy let bindings (lower to @cached_property).

It may not contain:

  • def __init__ — generated. tyc::manual_init.
  • Other def methods — put them in an impl block 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 shared

Since 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"

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 runtime
ApiUser(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 typesData crossing trust boundaries
Maximum performanceRuntime validation
No extra constraintsField(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"

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 @dataclass is the wrong fit but class! 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:yesby @dataclass
class! Foo(Base):noyes (calls super().__init__())
plain class Foo:nono (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: float

What class! changes

  • No @dataclass decorator. The class is emitted verbatim with whatever bases it declares.
  • __init__ is auto-synthesised when the body declares no def __init__ and at least one base is present. The synthesised constructor calls super().__init__(), then assigns every annotated field through self in 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 Exception

Before 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.Module
  • enum.Enum
  • typing.NamedTuple
  • unittest.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: Tensor

Constructor 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