Skip to content

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 None

What it emits

class User:
id: int
name: str = "anon"
email: str?

slots=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 == 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()) # False

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

  1. Data and behaviour are separable. The class shows the shape; impl blocks show what you can do with it. New methods can be added without touching the data definition.
  2. 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 runtime

frozen + 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: float

The 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 shared

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

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") raises ValidationError at runtime. The type checker catches the same thing at compile time, but model adds 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 controlData from outside (HTTP, JSON files, env, queues)
You want maximum performanceYou want runtime validation
You don’t need extra constraintsYou 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: float

What class! changes vs a plain class:

  • 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__() and then assigns every annotated field through self, 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 populated

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

domain/user.ty
class User:
id: int
name: str
# analytics/user_metrics.ty
extend 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 annotation

Emitted:

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.

src/users.ty
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(),
)

Notes:

  • UserInput is a model because it comes from outside (an HTTP body, say). Pydantic will reject {"email": "a@b", "name": "alice", "extra": "boom"} at construction time (because extra="forbid" is the default).
  • User is a class because it lives entirely inside the app — no validation needed, just a dataclass.
  • display and age_seconds are methods on User, defined in a separate impl block. The compiler merges them in.
  • create is 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 = id
error[tyc::manual_init]: classes do not declare `__init__`; the constructor is generated

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

Construct a fresh instance instead: let q: FrozenPoint = FrozenPoint(x=3.0, y=p.y).

What you’ve learned

  • class emits @dataclass(slots=True); model emits Pydantic BaseModel(extra="forbid"); class! is the escape hatch for framework bases.
  • Methods live in impl blocks with explicit self and self.NAME field 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