Skip to content

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

domain/user.ty
class User:
id: int
name: str
# analytics/user_metrics.ty
from domain.user import User
extend User:
def tracking_id(self) -> str:
return f"user-{self.id:08d}"

In 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()

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) because title has the static annotation str.
  • Un-annotated receivers continue to use native attribute lookup. some_dynamic.to_slug() raises AttributeError at 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 a pub * package facade counts as importing from every module it aggregates: from pkg import describe brings pkg/text.ty’s extensions into scope, and the facade’s __init__.py re-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::

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

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 before

The 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