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.

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 fixed interface list and lowered to the corresponding prototype slot. A direct extension method that is not part of that list is not visible through Shape, 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 subtype-of-subtype inheritance, field inheritance, super, or override priority;
  • no associated-type interfaces in an open type’s interface list;
  • no contribution from enable extensions 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.