Classes and Object-Oriented Programming
Classes in Dovetail provide object-oriented programming with encapsulation, inheritance, and polymorphism. Unlike records (which are pure data), classes combine data with behavior and support mutable state.
9.1 Class Definitions
Section titled “9.1 Class Definitions”A class is defined with the class keyword, followed by the class name, constructor parameters, and methods:
class Point(x: Int32, y: Int32) = public function getX(self): Int32 = self.x public function getY(self): Int32 = self.y public function sum(self): Int32 = self.x + self.yKey points:
- Constructor parameters define the class fields
- Methods are defined in the class body
Creating Instances
Section titled “Creating Instances”Create instances by calling the class name like a function:
let p = Point(3, 4)let x = p.getX() // 3let total = p.sum() // 7Field Access
Section titled “Field Access”Fields can be accessed directly (based on visibility):
class Point(public x: Int32, public y: Int32) = public function dummy(self): Int32 = 0
function getX(p: Point): Int32 = p.x // Direct field access9.2 Methods
Section titled “9.2 Methods”Instance Methods
Section titled “Instance Methods”Instance methods take self as the first parameter:
class Counter(mutable value: Int32) = public function getValue(self): Int32 = self.value public function increment(self): Unit = self.value = self.value + 1Static Methods (Smart Constructors)
Section titled “Static Methods (Smart Constructors)”Methods without self are static and called on the class itself. Static methods are commonly used as “smart constructors” to provide alternative ways to create instances:
class Point(x: Int32, y: Int32) = public function getX(self): Int32 = self.x public function origin(): Point = Point(0, 0) // Smart constructor public function fromSingle(v: Int32): Point = Point(v, v) // Smart constructor public function zero(): Int32 = 0 // Static utility
function main(): Unit = let p1 = Point.origin() // (0, 0) let p2 = Point.fromSingle(5) // (5, 5) let p3 = Point(3, 7) // Primary constructor ()Smart constructors can also perform validation:
class PositiveInt private (value: Int32) = public function get(self): Int32 = self.value
public function create(n: Int32): Option<PositiveInt> = if n > 0 then Some(PositiveInt(n)) else None
function main(): Unit = match PositiveInt.create(42) with case Some(p) => p.get() case None => 0 ()9.3 Class Body Fields
Section titled “9.3 Class Body Fields”Classes can include field declarations in the body using a unified let syntax. These fields can be stored fields, properties (computed getters), static fields, or static properties.
Stored Fields
Section titled “Stored Fields”Basic stored fields allocate memory in each instance:
class Rectangle(width: Int32, height: Int32) = let color: String = "blue" let name: String = "rectangle"
public function getColor(self): String = self.color public function getName(self): String = self.name
function main(): Unit = let r = Rectangle(3, 4) r.getColor() // "blue" r.getName() // "rectangle" ()Key points about stored fields:
- They are evaluated when the instance is created
- They are private by default (not accessible outside the class)
- They are immutable by default
- They can reference constructor parameters and other fields
- Non-let expressions in the class body run as side effects during construction
Property Fields
Section titled “Property Fields”Properties generate getter functions that are evaluated each time they’re accessed:
class Rectangle(width: Int32, height: Int32) = let property area: Int32 = self.width * self.height let property perimeter: Int32 = 2 * (self.width + self.height)
public function getArea(self): Int32 = self.area // Calls generated getter public function getPerimeter(self): Int32 = self.perimeter
function main(): Unit = let r = Rectangle(3, 4) r.getArea() // 12 - computed on access r.getPerimeter() // 14 - computed on access ()Key differences between stored fields and properties:
- Stored fields: Evaluated once during construction, stored in memory
- Properties: Evaluated every time they’re accessed, no storage
Static Stored Fields
Section titled “Static Stored Fields”Static fields belong to the class itself, not individual instances:
class Config(name: String) = public let static defaultTimeout: Int32 = 30 public let static maxRetries: Int32 = 3
function main(): Unit = Config.defaultTimeout // 30 - accessed on class, not instance ()Static fields are initialized once when the class is first loaded.
Static Property Fields
Section titled “Static Property Fields”Static properties are like static fields but computed each time they’re accessed:
class Math(value: Int32) = public let static property pi: Float64 = 3.14159265359 public let static property e: Float64 = 2.71828182846
public function dummy(self): Int32 = 0
function main(): Unit = Math.pi // 3.14159265359 - computed on access ()Mutable Fields
Section titled “Mutable Fields”Use let mutable for fields that can be changed after construction:
class Counter(initial: Int32) = let mutable count: Int32 = initial
public function increment(self): Unit = self.count = self.count + 1 public function getCount(self): Int32 = self.countChained Fields
Section titled “Chained Fields”Fields can depend on each other (in declaration order):
class Stats(base: Int32) = let doubled: Int32 = base * 2 let quadrupled: Int32 = doubled * 2 // Uses earlier field
public function getQuadrupled(self): Int32 = self.quadrupledSide Effects in Constructor Body
Section titled “Side Effects in Constructor Body”Any expression in the class body runs during construction:
class Logger(name: String) = let _ = debug("Creating logger: " ++ name) // Runs at construction
public function log(self, msg: String): Unit = debug(name ++ ": " ++ msg)Field Visibility
Section titled “Field Visibility”Fields can have visibility modifiers:
class Entity(id: Int32) = public let name: String = "entity" // Accessible everywhere private let secret: String = "classified" // Only within class let internal: String = "data" // Package-level (default)
public let property publicProp: Int32 = self.id * 2 private let property privateProp: Int32 = self.id * 3
public function dummy(self): Int32 = 09.4 Private Constructors
Section titled “9.4 Private Constructors”Use the private keyword after the class name to make the constructor private:
class Singleton private (value: Int32) = public function get(self): Int32 = self.value
public function instance(): Singleton = Singleton(42) // Only accessible here
function main(): Unit = // let s = Singleton(10) // Error: constructor is private let s = Singleton.instance() // OK - use smart constructor s.get() ()Private constructors are useful for:
- Singleton patterns
- Factory patterns where validation is required
- Ensuring instances are created only through smart constructors
9.5 Mutable Fields
Section titled “9.5 Mutable Fields”By default, class fields are immutable. Use mutable to allow modification:
class Counter(mutable value: Int32) = public function getValue(self): Int32 = self.value public function increment(self): Unit = self.value = self.value + 1 public function add(self, amount: Int32): Unit = self.value = self.value + amount
function main(): Unit = let c = Counter(0) c.increment() c.increment() c.add(10) c.getValue() // 12 ()You can mix mutable and immutable fields:
class Entity(id: Int32, mutable health: Int32) = public function getId(self): Int32 = self.id // id is immutable public function getHealth(self): Int32 = self.health public function damage(self, amount: Int32): Unit = self.health = self.health - amount // health is mutableAttempting to assign to an immutable field results in a compile error.
9.6 Inheritance
Section titled “9.6 Inheritance”Classes can extend other classes using extends:
class Animal(name: String) = public function getName(self): String = self.name
class Dog(name: String, breed: String) extends Animal(name) = public function getBreed(self): String = self.breed
function main(): Unit = let d = Dog("Rex", "German Shepherd") d.getName() // "Rex" - inherited from Animal d.getBreed() // "German Shepherd" - defined in Dog ()The extends clause specifies:
- The parent class name
- Arguments to pass to the parent’s constructor
Child classes inherit all methods from the parent and can access parent fields through inherited methods.
9.7 Abstract Classes
Section titled “9.7 Abstract Classes”Abstract classes cannot be instantiated directly and can contain abstract methods:
abstract class Shape(name: String) = public function getName(self): String = self.name // Concrete method public abstract function area(self): Int32 // Abstract method
class Square(name: String, side: Int32) extends Shape(name) = public override function area(self): Int32 = self.side * self.side // Must implement
function main(): Unit = // let s = Shape("test") // Error: cannot instantiate abstract class let sq = Square("square", 5) sq.area() // 25 ()Key points:
- Use
abstract classto define an abstract class - Use
abstract functionto declare methods without implementation - Abstract methods can only appear in abstract classes
- Concrete subclasses must implement all abstract methods
9.8 Sealed Abstract Classes
Section titled “9.8 Sealed Abstract Classes”A sealed abstract class is an abstract class whose subclasses are restricted to the same package. This allows the compiler to know all possible subclasses and perform exhaustiveness checking on pattern matches — just like enums.
Defining a Sealed Hierarchy
Section titled “Defining a Sealed Hierarchy”sealed abstract class Shape()final class Circle(public radius: Float64) extends Shape()final class Rectangle(public width: Float64, public height: Float64) extends Shape()Rules for subclasses of a sealed class:
- Must be in the same package
- Must be either
final class(leaf — cannot be extended) orsealed abstract class(intermediate — itself sealed)
Exhaustive Pattern Matching
Section titled “Exhaustive Pattern Matching”Because the compiler knows all subclasses, it can verify you’ve handled every case:
function area(s: Shape): Float64 = match s with case c: Circle => 3.14 * c.radius * c.radius case r: Rectangle => r.width * r.heightIf you forget a case, the compiler reports an error:
function area(s: Shape): Float64 = match s with case c: Circle => 3.14 * c.radius * c.radius // Error: non-exhaustive match: missing case for RectangleYou can still use a wildcard to catch remaining cases:
function describe(s: Shape): String = match s with case c: Circle => "circle" case _ => "other shape"Deep Sealed Hierarchies
Section titled “Deep Sealed Hierarchies”Sealed abstract classes can form multi-level hierarchies. Matching on an intermediate sealed class covers all its leaf subclasses:
sealed abstract class Shape()sealed abstract class Polygon() extends Shape()final class Triangle(public base: Float64) extends Polygon()final class Rectangle(public width: Float64) extends Polygon()final class Circle(public radius: Float64) extends Shape()
function describe(s: Shape): String = match s with case p: Polygon => "polygon" // Covers both Triangle and Rectangle case c: Circle => "circle"Sealed vs Enums
Section titled “Sealed vs Enums”Both sealed classes and enums represent a fixed set of alternatives with exhaustive matching. Choose based on your needs:
- Enums: Best for simple discriminated unions where all variants share the same type parameters. Variants are lightweight (no methods or trait implementations).
- Sealed classes: Best when subclasses need their own type parameters, methods, trait implementations, or mutable state. Each subclass is a full class with its own capabilities.
9.9 Generic Classes
Section titled “9.9 Generic Classes”Classes can have type parameters:
class Box<T>(value: T) = public function get(self): T = self.value
function main(): Unit = let intBox = Box(42) let strBox = Box("hello") intBox.get() // 42 ()Multiple Type Parameters
Section titled “Multiple Type Parameters”class Pair<A, B>(first: A, second: B) = public function getFirst(self): A = self.first public function getSecond(self): B = self.second
function main(): Unit = let p = Pair(1, "one") p.getFirst() // 1 p.getSecond() // "one" ()Mutable Generic Fields
Section titled “Mutable Generic Fields”class MutableBox<T>(mutable value: T) = public function get(self): T = self.value public function set(self, newValue: T): Unit = self.value = newValue
function main(): Unit = let box = MutableBox(10) box.set(20) box.get() // 20 ()Constrained Generic Classes
Section titled “Constrained Generic Classes”Use where clauses or inline constraints to restrict type parameters:
trait Countable = public function count(self): Int32
class Counter<T: Countable>(value: T) = public function getCount(self): Int32 = self.value.count()Or with where clause:
class Wrapper<T>(value: T) where T: Printable= public function fmt(self): String = self.value.format()9.10 Implementing Traits for Classes
Section titled “9.10 Implementing Traits for Classes”Generic classes can inherit trait method and property defaults. For methods with their own type parameters, see Generic Trait Defaults.
Classes can implement traits in two ways:
Inline Implementation
Section titled “Inline Implementation”Use implements in the class declaration:
trait Speakable = public function speak(self): String
class Dog(name: String) implements Speakable = public function speak(self): String = "Woof" public function getName(self): String = self.nameMultiple Traits
Section titled “Multiple Traits”Use and to implement multiple traits:
trait Speakable = public function speak(self): String
trait Nameable = public function getName(self): String
class Dog(name: String) implements Speakable and Nameable = public function speak(self): String = "Woof" public function getName(self): String = self.nameCombined with Inheritance
Section titled “Combined with Inheritance”Classes can extend a parent and implement traits:
class Dog(name: String) extends Pet(name) implements Speakable and Comparable = public function speak(self): String = "Woof" public function compare(self, other: Dog): Int32 = 0External Implementation
Section titled “External Implementation”You can also implement traits for classes externally:
trait Speakable = public function speak(self): String
class Dog(name: String) = public function bark(self): String = "Woof"
implement Speakable for Dog = public function speak(self): String = self.bark()This is useful when:
- You don’t own the class definition
- You want to add trait implementations later
- You’re implementing traits from other packages
9.11 Extension Properties
Section titled “9.11 Extension Properties”Named extensions add computed properties without adding stored fields. Import the
extension explicitly, even when it is declared in the same package. Extension
properties use property name(self): Type, while class-body computed fields use
let property name: Type.
Complete example (checked in CI) — no extra dependencies:
package classes
import classes.RectangleHelpers
class Rectangle(public width: Int32, public height: Int32) = public function dimensions(self): (Int32, Int32) = (self.width, self.height)
extension RectangleHelpers for Rectangle = property area(self): Int32 = self.width * self.height function scaled(self, factor: Int32): Rectangle = Rectangle(self.width * factor, self.height * factor)
function main(): Unit = let rectangle = Rectangle(3, 4) assert rectangle.area == 12 assert rectangle.scaled(2).area == 48
test "extension properties compute from public fields" = main()Properties are evaluated on access. Extensions follow normal visibility rules and cannot use private construction to bypass a type’s smart constructors. Records and enums can also be extended; see Extension Methods.
9.12 Extension Methods for Classes
Section titled “9.12 Extension Methods for Classes”RectangleHelpers above defines both a property and a method. Call its method with
ordinary receiver syntax, such as rectangle.scaled(2), after importing the named
extension. An extension does not change the class’s inheritance hierarchy.
Generic extensions declare their parameters on the extension name:
class Container<T>(value: T) = function getValue(self): T = self.value
extension ContainerHelpers<T> for Container<T> = function map<U>(self, transform: T => U): Container<U> = Container(transform(self.getValue()))Import yourPackage.ContainerHelpers at the call site. See
Advanced Generics for bounds.
9.13 Visibility Modifiers
Section titled “9.13 Visibility Modifiers”Control access to classes, fields, and methods with visibility modifiers:
Class Visibility
Section titled “Class Visibility”public class PublicPoint(x: Int32, y: Int32) = function getX(self): Int32 = self.x
private class PrivateHelper(value: Int32) = function get(self): Int32 = self.value
internal class InternalUtil(data: String) = // Default visibility function getData(self): String = self.dataField Visibility
Section titled “Field Visibility”class Entity( public id: Int32, // Accessible everywhere private secret: String, // Only within this class internal data: Int32 // Within this package; fields default to private) = function getId(self): Int32 = self.id function getSecret(self): String = self.secret // OK - inside classPrivate fields are not accessible outside the class:
function main(): Unit = let e = Entity(1, "secret", 42) e.id // OK - public // e.secret // Error - private field 0 ()Method Visibility
Section titled “Method Visibility”Class fields and methods default to private. Expose operations with public or
internal explicitly. This differs from top-level declarations, which default to
internal.
class Calculator(value: Int32) = private function helper(self): Int32 = self.value * 2 public function compute(self): Int32 = self.helper() + 1 internal function packageHelper(self): Int32 = 0 // Explicit package visibilityCombining with Mutable
Section titled “Combining with Mutable”Visibility can be combined with mutable:
class Counter(private mutable count: Int32) = function increment(self): Unit = self.count = self.count + 1 function getCount(self): Int32 = self.count9.14 Classes vs Records
Section titled “9.14 Classes vs Records”| Feature | Class | Record |
|---|---|---|
| Methods | Defined in body | Via associated modules or named extensions |
| Mutability | Mutable fields supported | Always immutable |
| Inheritance | Supported | Not supported |
| Abstract | Supported | Not supported |
| Identity | Reference identity | Value equality |
| Use case | Stateful objects | Pure data |
When to use classes:
- You need mutable state
- You need inheritance hierarchies
- You’re modeling entities with identity
- You need encapsulation with private fields
When to use records:
- You’re modeling pure data
- You want value semantics
- You don’t need inheritance
- Immutability is preferred
9.15 Class Identity
Section titled “9.15 Class Identity”ClassIdentity.equals asks whether two references name the same instance, independently of its fields or any Equatable implementation:
class IdentityCounter(public mutable value: Int32)
function checkIdentity(): Unit = let first = IdentityCounter(1) let alias = first let second = IdentityCounter(1) assert ClassIdentity.equals(first, alias) assert !ClassIdentity.equals(first, second) let hash = ClassIdentity.hash(first) first.value = 2 assert ClassIdentity.equals(first, alias) assert ClassIdentity.hash(alias) == hashBoth functions are available for every class, including generic classes and abstract base references. Upcasts preserve identity and the hash. To compare related types explicitly, use ClassIdentity.equals<Base>(left, right). Unrelated classes have no implicit common object type for this call.
Every class instance reserves one hidden hash slot. Its value is assigned on the first ClassIdentity.hash call and stays fixed for that object’s lifetime. Equality does not initialize the hash. Hashes may collide: equal hashes do not prove that objects are identical. They are not persistent identifiers and must not be used as unique handles.
These primitives require no annotation. Classes still need explicit Equatable and Hashable implementations to use identity semantics through those traits; see Identity Equality and Hashing. Generic functions can call both primitives under where T: class.
Summary
Section titled “Summary”- Class Definitions: Combine constructor parameters with methods using
class Name(fields) = methods - Instance Methods: Take
selfparameter; Static Methods: Noself, called on class - Smart Constructors: Use static methods to provide alternative ways to create instances with validation
- Class Body Fields: Use unified
letsyntax for stored fields, properties, static fields, and static properties - Property Fields: Use
let propertyfor computed getters evaluated on each access - Static Fields: Use
let staticfor class-level fields;let static propertyfor class-level computed getters - Private Constructors: Use
class Name private (fields)to restrict instantiation to smart constructors - Mutable Fields: Use
mutablekeyword to allow field modification - Inheritance: Use
extends Parent(args)to inherit from another class - Abstract Classes: Use
abstract classandabstract functionfor unimplemented methods - Sealed Abstract Classes: Use
sealed abstract classto restrict subclasses to the same package with exhaustive pattern matching - Generic Classes: Type parameters with
class Name<T>, constraints withwhere T: Trait - Trait Implementation: Inline with
implements Traitor external withimplement Trait for Class - Extension Properties: Add computed properties with
property name(self): Typein named extensions - Extension Methods: Add methods to classes with
extension Helpers for ClassName - Visibility: Control access with
public,private,internalon classes, fields, methods, and properties
