Skip to content
Noodle
InstallLearnPlayground
GitHub

Open types

An opentype is an abstract nominal type whose set of concrete datatypes can grow in other modules. It is useful when a value must hold several concrete variants while each variant supplies its own implementation. An open type is not a general inheritance hierarchy: it has no constructors or fields of its own, and version 1 does not support generic open types.

List the interfaces that every subtype must implement:

export interface IShape
method area(self : Self) -> Int
end
export opentype Shape interfaces IShape end

An interface listed by an open type must be usable as a runtime dictionary. In particular, it cannot declare associated types, and the method sets of the listed interfaces must not overlap. An open type with no interfaces clause is still useful as an extensible tag type, but it has no method obligations.

An opentype can extend one or more parent open types. A subtype value flows into its open type and transitively into every ancestor, and the child’s effective interface set is its own list plus every inherited interface:

export opentype EventTarget end
export opentype Node extends EventTarget end
@js_recognizer("recognize_html_element")
export extern datatype HtmlElement of Node
@js_tag(7) HtmlElement
end
func accept_event_target(_target : EventTarget) -> Unit do
end
extern func get_html_element_raw() -> HtmlElement
func inheritance_probe() -> Unit do
accept_event_target(get_html_element_raw());
()
end

Parents must be open types; self-parents, cycles, and duplicate parents are rejected. Interface method sets must stay pairwise disjoint across the whole union — there are no overrides. An extern datatype ... of leaf is admitted on interface-less chains (no interfaces anywhere on the chain): obligations pass vacuously, explicit @js_tag values are kept, and no vtable is installed on host prototypes.

A normal datatype becomes a subtype with an of clause. Its extensions list supplies exactly one provider for every interface required by the open type:

export interface IShape
method area(self : Self) -> Int
end
export opentype Shape interfaces IShape end
datatype Circle of Shape extensions CircleShape
Circle{radius: Int}
end
extension CircleShape : IShape for Circle
method area(circle : Circle) -> Int do
circle.radius * circle.radius
end
end
func area_of(shape : Shape) -> Int do
shape.area()
end
func main() -> Unit do
circle = Circle::Circle{radius: 3};
Debug.trace(area_of(circle));
end

Widening Circle to Shape does not allocate a wrapper. The value keeps its datatype identity, and the constructor prototype carries the provider needed for the open type’s interface slot. A subtype that omits a provider, provides two providers for one required interface, or uses a provider with contextual prerequisites is rejected at compile time.

The method marker says that a member participates in receiver dot-call lookup. Its first positional parameter is the receiver and must have the interface’s Self type. An extension implementing a method interface member must also write method:

interface IShape
method area(self : Self) -> Int
end
datatype Circle extensions CircleShape
Circle{radius: Int}
end
extension CircleShape : IShape for Circle
method area(circle : Circle) -> Int do
circle.radius * circle.radius
end
end
func main() -> Unit do
circle = Circle::Circle{radius: 3};
Debug.trace(circle.area());
end

An ordinary func member is still stored in the extension dictionary, but it is called through that dictionary and is not a dot-call method. This is useful for operations whose receiver is an open value and whose provider must be selected explicitly:

export interface ILabel
func label(value : Self) -> String
end
export opentype Item interfaces ILabel end
datatype Number of Item extensions NumberLabel
Number{value: Int}
end
extension NumberLabel : ILabel for Number
func label(number : Number) -> String do
number.value.to_string()
end
end
func label_item(
item : Item,
?{extension Label: ILabel for item},
) -> String do
Label.label(item)
end

The extension question above is a value-level witness. At runtime it reads the ILabel slot carried by the concrete subtype. Once a value has been widened to Item, a caller can pass it to label_item; the omitted answer is resolved from that value’s prototype. This is not the same as a compile-time contextual question over a statically known T: the subject is the open value itself, so the provider can vary from one value to the next.

For a method member, a call such as shape.area() is resolved from the open type’s effective interface set — its own interfaces plus inherited ones — and lowered to the corresponding prototype slot. When the subtree crosses into host values, the call instead dispatches directly to the single covering provider body, since host prototypes carry no installed slots.

An interface method wins over a same-named direct extension: the interface is the only contract stable across every subtype. When no interface matches, a direct extension for the receiver open type or one of its ancestors that is lexically visible through with extensions resolves as a fallback to an ordinary direct call. A direct extension method on a subtype is still never visible through the open type, even if one particular subtype defines it. This keeps the open-type method set stable and prevents a subtype from changing the meaning of a call through an already widened value.

For an ordinary func member, use the explicit witness form shown above: Label.label(item). The dictionary field is called directly and does not add a hidden receiver argument; pass the receiver as an ordinary argument.

An open type has an unknown, extensible constructor set. A switch over an open type can safely test known subtype constructors, but it must include a wildcard or binder fallback. See Advanced pattern matching for downcast patterns and the exhaustiveness rule.

Open types intentionally leave several features out of the first version:

  • no constructors or payloads on the opentype declaration itself;
  • no generic open types;
  • no datatype subtype chains, field inheritance, super, or override priority (opentype parents with transitive widening are supported, but without overrides);
  • no associated-type interfaces in an open type’s interface list;
  • no contribution from lexically scoped with extensions providers to an open type’s required vtable; the provider must be associated on the subtype.

Use an ordinary datatype when the set of constructors is closed and exhaustive matching is the main operation. Use an interface and extension without an open type when the concrete subject type is already known at each call site.

Next: Structural capabilities.