Skip to content

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 == 0

Multiple impl blocks

Allowed, and merged. They may live in different files:

domain/user.ty
class User:
id: int
impl User:
def display(self) -> str: ...
# auth/admin.ty
impl 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.value

Re-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 + 1

The 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 impl but lives in a different file.
  • Extensions on built-ins — adds methods that desugar to free functions, with call-site rewrites.

Extending user-defined classes

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

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)

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 before

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

  • str
  • bytes
  • int
  • float
  • bool
  • list
  • dict
  • set
  • frozenset
  • tuple

Extensions on other built-ins (type, object, range, etc.) are not currently rewritten.

Where next