Classes and Models
Typhon’s class is deliberately minimal: no __init__, no explicit self-in-body. The compiler emits a @dataclass(slots=True) by default, a Pydantic BaseModel for model, a bare class with no decorator and no synthesised constructor for plain class, or a bare class with synthesised __init__ calling super().__init__() for class!. Methods live in impl blocks, Rust-style.
A first class
class User: id: int name: str = "anon" email: str?Three fields. Two have explicit defaults (or implicit ones — email: str? defaults to None). One is required (id).
The constructor is generated for you:
let u: User = User(id=1, name="Alice", email="alice@example.com")let anon: User = User(id=2) # name defaults to "anon", email to NoneWhat it emits
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 is the default — instances don’t carry a per-object __dict__, which saves memory and catches typos (u.emial = "x" is an AttributeError).
Adding methods with impl
Methods live in a separate impl block. Take an explicit self parameter and access fields as self.NAME. The desugarer merges every impl block back into the class at compile time:
class User: id: int name: str email: str?
impl User: def display(self) -> str: return f"{self.name} <{self.email}>" if self.email is not None else self.name
def is_admin(self) -> bool: return self.id == 0from __future__ import annotationsimport dataclasses
@dataclasses.dataclass(slots=True)class User: id: int name: str email: str | None
def display(self) -> str: return ( f"{self.name} <{self.email}>" if self.email is not None else self.name )
def is_admin(self) -> bool: return self.id == 0(email: str? lowers to str | None; it does not auto-default to None — callers must pass an explicit email= argument. Use email: str? = None if you want the default.)
Calls look normal:
let u: User = User(id=1, name="Alice", email="alice@example.com")print(u.display()) # Alice <alice@example.com>print(u.is_admin()) # FalseYou can split impl blocks across files (e.g. keep User in models.ty and add domain methods from auth.ty). The desugarer collects them all.
Why impl instead of methods-in-class?
Two reasons:
- Data and behaviour are separable. The
classshows the shape;implblocks show what you can do with it. New methods can be added without touching the data definition. - Cross-file extensions.
impl ClassName:in another file adds methods to the same class — invaluable when domain logic and data definitions live in different modules.
Mutability of fields
Fields are mutable by default; the frozen modifier on the class disables field reassignment:
class Point: x: float y: float
let p: Point = Point(x=1.0, y=2.0)p.x = 5.0 # ✅ allowed
class FrozenPoint frozen: x: float y: float
let q: FrozenPoint = FrozenPoint(x=1.0, y=2.0)q.x = 5.0 # ❌ tyc::frozen_assign at compile time; FrozenInstanceError at runtimefrozen + inheritance — modifier comes between name and base
When combining frozen with a base class, the modifier sits between the class name and the parenthesised base list, not after it:
class Shape: name: str
class Square frozen(Shape): # ✅ parses side: float
class Square(Shape) frozen: # ❌ does not parse side: floatThe same rule applies to generics:
class Stack[T] frozen: # ✅ items: tuple[T, ...]
class Stack[T] frozen(BaseStack): # ✅ items: tuple[T, ...]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 one mutable literal:
class Bucket: items: list[str] = [] tags: dict[str, int] = {}
let a: Bucket = Bucket()let b: Bucket = Bucket()a.items.append("x")print(b.items) # [] — not sharedSince v0.9.0 the in-process VM also honours the factory (it previously executed the rewritten code path correctly under tyc build, but tyc run shared 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.
model — Pydantic emission
When you need runtime validation — typical for API boundaries — use model:
model ApiUser: id: int email: str name: str = "anon"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"Two things to notice:
extra="forbid"is the default. Pydantic’s stock setting is"ignore", which silently drops unknown fields — exactly the kind of quiet failure Typhon exists to prevent.- Pydantic validates at construction time, not at compile time.
ApiUser(id="oops", email="a@b")raisesValidationErrorat runtime. The type checker catches the same thing at compile time, butmodeladds a second line of defence for data crossing trust boundaries (HTTP requests, file inputs).
When to use model vs class
Use class (dataclass) | Use model (Pydantic) |
|---|---|
| Internal types, fully under your control | Data from outside (HTTP, JSON files, env, queues) |
| You want maximum performance | You want runtime validation |
| You don’t need extra constraints | You need Field(min_length=1, ...) style validators |
You can mix both freely in one project.
plain class — stock Python class semantics
When you want a bare Python class with no decorator and no synthesised constructor — body emits verbatim, attributes only exist on instances once user code assigns them — reach for plain class. This is the symmetric form of frozen class, and the canonical Python-interop escape hatch:
plain class Bag: items: list[str] label: str = "unsorted"
# Emitted: bare `class Bag:` with no @dataclass, no __init__.Use it for metaclass-driven libraries (Textual app classes, Django ORM with descriptor-based fields, SQLAlchemy declarative bases) that set instance attributes dynamically — the dataclass decorator’s slots=True would conflict with the framework’s own attribute layout, and you don’t want a generated __init__ either.
For the related auto-skip rule: a plain class Foo(Base): whose base is Enum, IntEnum, StrEnum, Flag, IntFlag, ABC, ABCMeta, Protocol, TypedDict, or NamedTuple is automatically emitted without @dataclass — no plain class marker needed. Project-specific bases can be added via [emit] skip-decoration-bases.
class! — the escape hatch for framework bases (with synthesised __init__)
Some classes can’t be expressed as dataclasses: torch.nn.Module, enum.Enum, typing.NamedTuple, unittest.TestCase, Django models, SQLAlchemy declarative bases — anything whose base class needs a non-trivial __init__ to run before fields are assigned. For those, use class!:
import torch.nn as nn
class! MyModel(nn.Module): layer: nn.Linear dropout: float
def forward(self, x): return self.layer(x)class! MyModel(nn.Module): layer: nn.Linear dropout: floatclass 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 vs a plain class:
- 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__()and then assigns every annotated field throughself, in source order.- A hand-written
__init__is preserved verbatim. Use this when the base class needs configuration arguments that aren’t 1:1 with your declared fields.
Typed exceptions with structured fields
A common class! pattern is a typed exception subclass — extending Exception with structured fields the handler can read. Since v0.9.0 the synthesised __init__ runs under the in-process VM as well, so the same source executes 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 with both fields populatedBefore v0.9.0 the VM didn’t run the synthesised __init__ on class! instances, so e.code raised AttributeError under tyc run (but worked fine in the compiled build). Exception-type matching also walks the MRO since v0.9.0, so except HttpError matches HttpError-subclasses.
See Class Escape Hatches for the framework-by-framework patterns.
extend — adding methods to existing classes
extend is impl’s twin for cases where the class is declared elsewhere:
class User: id: int name: str
# analytics/user_metrics.tyextend User: def tracking_id(self) -> str: return f"user-{self.id:08d}"The merge happens at desugar; downstream callers see a single class with both sets of methods.
Extending built-ins
extend BUILTIN: (str, list, int, dict, …) extracts each method to a module-level free function and rewrites call sites when the receiver’s static type is known to be that built-in — an annotated binding, a literal, a known class’s field, a call with a declared return type, a loop variable:
extend str: def to_slug(self) -> str: return self.lower().replace(" ", "-")
let title: str = "Hello World"let slug: str = title.to_slug() # ✅ resolved via type annotationEmitted:
def __typhon_ext_str__to_slug(self: str) -> str: return self.lower().replace(" ", "-")
title: str = "Hello World"slug: str = __typhon_ext_str__to_slug(title)No monkey-patching of built-ins. Un-annotated receivers (some_dynamic.to_slug()) raise AttributeError at runtime, matching Python’s existing semantics. The rewrite is strictly opt-in by type annotation.
Inheritance
Single inheritance works the way it does in Python. You spell the parent class in parentheses:
class Animal: name: str
impl Animal: def speak(self) -> str: return "..."
class Dog(Animal): breed: str
impl Dog: def speak(self) -> str: return "woof"For most domain modelling, prefer sealed unions (Sealed Unions and Match) over inheritance. They give you exhaustive matching, and the checker enforces variant coverage in a way subclassing cannot.
Putting it together
A worked example: a tiny user system that mixes a model (for the API), a class (for internal state), and impl blocks for methods.
from datetime import datetime
model UserInput: email: str name: str = "anon"
class User: id: int email: str name: str created_at: datetime
impl User: def display(self) -> str: return f"{self.name} <{self.email}>"
def age_seconds(self, now: datetime) -> float: return (now - self.created_at).total_seconds()
def create(input: UserInput, id: int) -> User: return User( id=id, email=input.email, name=input.name, created_at=datetime.now(), )from __future__ import annotationsfrom pydantic import BaseModel, ConfigDictimport dataclassesfrom datetime import datetime
class UserInput(BaseModel): model_config = ConfigDict(extra="forbid") email: str name: str = "anon"
@dataclasses.dataclass(slots=True)class User: id: int email: str name: str created_at: datetime
def display(self) -> str: return f"{self.name} <{self.email}>"
def age_seconds(self, now: datetime) -> float: return (now - self.created_at).total_seconds()
def create(input: UserInput, id: int) -> User: return User(id=id, email=input.email, name=input.name, created_at=datetime.now())Notes:
UserInputis amodelbecause it comes from outside (an HTTP body, say). Pydantic will reject{"email": "a@b", "name": "alice", "extra": "boom"}at construction time (becauseextra="forbid"is the default).Useris aclassbecause it lives entirely inside the app — no validation needed, just a dataclass.displayandage_secondsare methods onUser, defined in a separateimplblock. The compiler merges them in.createis a free function. There’s no requirement that constructor-like functions live as static methods; module-level functions are first-class.
Common mistakes
Writing __init__
class User: id: int
def __init__(self, id: int) -> None: # ❌ self.id = iderror[tyc::manual_init]: classes do not declare `__init__`; the constructor is generatedFix: drop the method. Use field defaults for “convenience” constructors, or write a free function.
Putting methods inside class
class User: id: int
def display(self) -> str: # ❌ wrong place return f"user {self.id}"Fix: move into an impl User: block.
Mutating a frozen instance
class FrozenPoint frozen: x: float y: float
let p: FrozenPoint = FrozenPoint(x=1.0, y=2.0)p.x = 3.0 # ❌ tyc::frozen_assignConstruct a fresh instance instead: let q: FrozenPoint = FrozenPoint(x=3.0, y=p.y).
What you’ve learned
classemits@dataclass(slots=True);modelemits PydanticBaseModel(extra="forbid");class!is the escape hatch for framework bases.- Methods live in
implblocks with explicitselfandself.NAMEfield access. extend ClassName:adds methods to existing user-defined classes from elsewhere.extend BUILTIN:extracts to free functions with static-receiver rewrites — no monkey-patching.- Prefer composition or sealed unions over inheritance for domain modelling.
Where next
- Error Handling — typed errors with
Result[T, E]. - Sealed Unions and Match — closed sums with exhaustive
match. - Class Escape Hatches —
class!patterns fortorch.nn.Module,Enum,unittest.TestCase, etc. implandextend— the reference page.