extend → free functions
extend ClassName: for a user-defined class is identical to impl when the class lives in the same module — methods merge into the class body at desugar. For an imported class, the methods are patched onto it when the extending module is imported.
extend BUILTIN: (where BUILTIN is str, list, dict, etc.) is different: 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, a known class’s field, a call with a declared return type, a subscript on a typed container, a loop variable). Receivers the pass cannot type are left alone.
User-defined class
class User: id: int name: str
# analytics/user_metrics.tyfrom domain.user import User
extend User: def tracking_id(self) -> str: return f"user-{self.id:08d}"@dataclass(slots=True)class User: id: int name: str
# build/analytics/user_metrics.pyfrom domain.user import User
def __typhon_extend_User__tracking_id(self) -> str: return f"user-{self.id:08d}"
User.tracking_id = __typhon_extend_User__tracking_idIn the class’s own module, the desugar pass merges every impl and extend over the class into its definition. For an imported class, the patch runs when the extending module is imported, so the methods are visible — to the checker and at runtime — in the extending module and in every module that imports it, by name, as a module, or through a pub * facade that aggregates it. A module that does not import the extending module has no guarantee the patch ran, and the checker still reports tyc::attribute_not_found there; import it for its effect (import analytics.user_metrics as _user_metrics).
Built-in extension
extend str: def to_slug(self) -> str: return self.lower().replace(" ", "-")
let title: str = "Hello World"let slug: str = title.to_slug()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)Key points:
- The method becomes a free function with a Typhon-internal name (
__typhon_ext_<TYPE>__<METHOD>). - The call site
title.to_slug()is rewritten to__typhon_ext_str__to_slug(title)becausetitlehas the static annotationstr. - Un-annotated receivers continue to use native attribute lookup.
some_dynamic.to_slug()raisesAttributeErrorat runtime, matching Python’s existing semantics. - Across modules, an extension is visible in every module that imports from the declaring module. The rewritten call imports the helper (
from text import __typhon_ext_str__to_slug__). Importing from apub *package facade counts as importing from every module it aggregates:from pkg import describebringspkg/text.ty’s extensions into scope, and the facade’s__init__.pyre-exports the helpers.
Why this design
We could have monkey-patched str at module-load time:
str.to_slug = lambda self: self.lower().replace(" ", "-")…but that has global effect. Every str in the entire process gets a new method, regardless of whether the project that called it expected it. That’s a recipe for cross-library footguns.
Typhon’s approach is strictly opt-in: only static-annotation-matching call sites are rewritten. The extension is invisible to code that didn’t ask for it.
Recognised built-ins
The set that supports extend BUILTIN::
strbytesintfloatboollistdictsetfrozensettuple
Other built-in types (type, object, range, etc.) are not currently rewritten.
Dispatching on parameterised receivers (v0.9.0)
Until v0.9.0 the rewrite only fired against bare-container annotations (xs: list); a list[int]-annotated receiver fell through to tyc::attribute_not_found. v0.9.0 consults the synthetic __typhon_builtin_ext_list (and the parallel _str / _dict / _set / _frozenset / _tuple / _bytes / _int / _float / _bool) class shape before attribute_not_found fires, so parameterised receivers 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 extension is still defined on the bare container (extend list: — not extend list[T]:), because the lowered free function is a single module-level definition that has to accept every element type. The receiver type is still required at the call site (Rule 1).
Extending parameterised built-ins (roadmap)
extend list[int]: def sum_squared(self) -> int: return sum(x * x for x in self)extend list[int]: with a parameterised receiver type — for cases where the extension only makes sense at a specific element type — parses but is not yet dispatched separately from the bare-container extend list: form. Roadmapped.
Where next
implandextendreference — the full surface.- Classes and Models (tour) — teaching page.