impl and extend
impl
impl ClassName: attaches methods to a class declared in the same project. The desugarer merges the methods into the class body.
class User: id: int name: str
impl User: def display(self) -> str: return f"{self.name} (#{self.id})"
def is_admin(self) -> bool: return self.id == 0import dataclasses
@dataclasses.dataclass(slots=True)class User: id: int name: str
def display(self) -> str: return f"{self.name} (#{self.id})"
def is_admin(self) -> bool: return self.id == 0Multiple impl blocks
Allowed, and merged. They may live in different files:
class User: id: int
impl User: def display(self) -> str: ...
# auth/admin.tyimpl User: def is_admin(self) -> bool: ...Both methods land on User at desugar time.
Methods take explicit self
impl User: def hi(self) -> str: # ✅ return f"hi, {self.name}"
def hi() -> str: # also accepted — `self` is auto-injected return "hello"The desugar pass injects self (or cls for @classmethod methods) as the first parameter if you omit it, so both forms emit identical Python. Convention is to write self explicitly — it documents the receiver and matches how the method body reads. Earlier docs suggested an implicit-self body form (bare name resolving to self.name); that is not implemented — the body must use self.NAME explicitly.
Generic classes
class Box[T]: value: T
impl[T] Box[T]: def get(self) -> T: return self.valueRe-declare the generic parameters with impl[T]. The parameter names must match the class declaration.
Distributing impl over a sealed-union alias
impl[T] AliasName[T]: over a sealed-union alias distributes every method body across every variant of the alias. Useful for ADT walks where each variant should expose the same operation:
class Cons[T] frozen: head: T tail: LL[T]
class Nil[T] frozen: pass
type LL[T] = Cons[T] | Nil[T]
impl[T] LL[T]: def is_empty(self) -> bool: match self: case Nil(): return True case Cons(_, _): return False
def length(self) -> int: mut cur: LL[T] = self mut count: int = 0 while True: match cur: case Nil(): return count case Cons(_, tail): cur = tail count = count + 1The lowering emits the body once per variant — Cons.is_empty(self), Nil.is_empty(self), Cons.length(self), Nil.length(self). Any diagnostic raised by the same line across multiple variants is now deduplicated since v0.9.0, so a 10-variant union no longer reports 10 identical errors. (The synthetic line numbers each surviving diagnostic points at are still inside the preprocessed buffer past EOF of your source; the proper source-map rewrite is tracked for the next release.)
extend
extend ClassName: is impl’s twin for two cases:
- Cross-module additions to user-defined classes — semantically identical to
implbut lives in a different file. - Extensions on built-ins — adds methods that desugar to free functions, with call-site rewrites.
Extending user-defined classes
class User: id: int name: str
# analytics/user_metrics.tyextend User: def tracking_id(self) -> str: return f"user-{self.id:08d}"Functionally the same as impl User: in another file. extend is the recommended spelling for cross-module additions because the keyword signals intent (“I am adding to a type defined elsewhere”).
Extending built-ins
extend str: def to_slug(self) -> str: return self.lower().replace(" ", "-")For built-in types (str, list, int, dict, bytes, set, frozenset, tuple), each method is extracted to a module-level free function and call sites are rewritten when the receiver’s static type is known to be that built-in — an annotated or evidently-initialised binding, a literal or f-string, a field or @property of a known class (self.title.slug()), a call with a declared return type (same-module or imported, impl methods, chained extension calls), a subscript on a list[T] / dict[K, V], or a loop / comprehension variable:
extend str: def to_slug(self) -> str: return self.lower().replace(" ", "-")
def main() -> None: let title: str = "Hello World" let slug: str = title.to_slug() print(slug)def __typhon_ext_str__to_slug__(self: str) -> str: return self.lower().replace(" ", "-")
def main() -> None: title: str = "Hello World" slug: str = __typhon_ext_str__to_slug__(title) print(slug)No monkey-patching. The rewrite is strictly opt-in by static annotation. An un-annotated receiver (some_dynamic.to_slug()) raises AttributeError at runtime — Python’s existing semantics.
Dispatching extend list: over list[T] receivers (v0.9.0)
Until v0.9.0 the rewrite only fired against list-typed bindings — list[int] and list[str] fell through to tyc::attribute_not_found. v0.9.0 consults the synthetic __typhon_builtin_ext_list class shape before attribute_not_found fires, so the parameterised forms dispatch correctly:
extend list: def first_or[T](self, default: T) -> T: return self[0] if self else default
def head(xs: list[int]) -> int: return xs.first_or(0) # ✅ since v0.9.0 — was attribute_not_found beforeThe receiver’s type must be knowable (Rule 1): a bare-list annotation is fine, and so is a call whose declared return is list[int], but a match capture, an unannotated lambda parameter or a with … as target is left as a native attribute access and raises AttributeError at runtime. An extension travels with the module that declares it — import that module (from pkg.text import describe), not only a pub * facade of it.
Which built-ins are extendable?
The recognised set:
strbytesintfloatboollistdictsetfrozensettuple
Extensions on other built-ins (type, object, range, etc.) are not currently rewritten.
Where next
- Classes and Models (tour) — teaching page.
class,model,class!— class declaration forms.extend→ free functions (lowering) — how the rewrite works.