Skip to content

Classes and Objects

Onion is an object-oriented language with full support for classes, inheritance, and interfaces.

Class Definition

Basic Class

Define a class with the class keyword:

class Person {
  val name: String
  var age: Int

  public:
    def this(n: String, a: Int) {
      this.name = n
      this.age = a
    }

    def greet: String = "Hello, I'm " + this.name
}

Creating Objects

Instantiate objects with the new keyword:

val person: Person = new Person("Alice", 30)
println(person.greet())  // "Hello, I'm Alice"

Primary Constructors

The concise way to declare a class: parameters after the class name form the primary constructor. val/var parameters become public fields (final/mutable) assigned automatically; plain parameters exist only in the constructor (useful for superclass arguments). Defaults and named arguments work like everywhere else.

class Point(val x: Int, val y: Int) {
public:
  def dist(): Int { return this.x * this.x + this.y * this.y }
}

class Conf(val host: String = "localhost", var port: Int = 8080)

class Animal(val name: String)
class Dog(name: String, val breed: String) : Animal(name)   // body-less is fine

val p = new Point(3, 4)        // p.x, p.y readable; p.x = 9 is an error (val)
val c = new Conf(port = 9090)  // host defaults to "localhost"

def this(...) constructors remain available for classes that need explicit bodies or multiple overloads.

Fields

Instance Fields

Declare instance fields with val (immutable) or var (mutable), and access them via this.field:

class Counter {
  var count: Int

  public:
    def this {
      this.count = 0
    }

    def increment {
      this.count = this.count + 1
    }

    def getCount: Int = this.count
}

Access Modifiers

Members are private by default. Use public: to mark public members:

class BankAccount {
  var balance: Double  // Private (default)
  val accountNumber: String  // Private

  public:
    val owner: String  // Public

    def this(owner: String, initial: Double) {
      this.owner = owner
      this.balance = initial
      this.accountNumber = "UNKNOWN"
    }

    def deposit(amount :Double) {  // Public method
      this.balance = this.balance + amount
    }

    def getBalance: Double = this.balance  // Public method
}

Static Members

Static members belong to the class, not instances:

class MathUtils {
  static val PI: Double = 3.14159

  public:
    static def square(x: Double): Double = x * x

    static def circleArea(radius: Double): Double = MathUtils::PI * radius * radius
}

// Access static members with ::
val pi: Double = MathUtils::PI
val area: Double = MathUtils::circleArea(5.0)

Constructors

Default Constructor

Define constructors with def this:

class Point {
  val x: Int
  val y: Int

  public:
    def this(x: Int, y: Int) {
      this.x = x
      this.y = y
    }
}

val point: Point = new Point(10, 20)

Multiple Constructors

Overload constructors for different initialization patterns:

class Rectangle {
  val width: Int
  val height: Int

  public:
    def this {
      this.width = 0
      this.height = 0
    }

    def this(size: Int) {
      this.width = size
      this.height = size
    }

    def this(w: Int, h: Int) {
      this.width = w
      this.height = h
    }
}

val rect1: Rectangle = new Rectangle()
val rect2: Rectangle = new Rectangle(10)
val rect3: Rectangle = new Rectangle(10, 20)

Calling Super Constructors

To call a superclass constructor, add a super-initializer list: def this(args): (superArgs) { ... }.

Methods

Instance Methods

Methods that operate on instance data:

class Circle {
  val radius: Double

  public:
    def this(r: Double) {
      this.radius = r
    }

    def area: Double = 3.14159 * this.radius * this.radius

    def circumference: Double = 2.0 * 3.14159 * this.radius
}

val circle: Circle = new Circle(5.0)
println("Area: " + circle.area())

Method Overloading

Multiple methods with the same name but different parameters:

class Printer {
  public:
    def print(value :Int) {
      println("Int: " + value)
    }

    def print(value :String) {
      println("String: " + value)
    }

    def print(value :Double) {
      println("Double: " + value)
    }
}

val printer: Printer = new Printer
printer.print(42)
printer.print("Hello")
printer.print(3.14)

Getter and Setter Methods

class Person {
  var name: String
  var age: Int

  public:
    def getName: String = this.name

    def setName(name :String) {
      this.name = name
    }

    def getAge: Int = this.age

    def setAge(age :Int) {
      if age >= 0 {
        this.age = age
      }
    }
}

The self Reference

Access the current instance with self:

import {
  javax.swing.JButton;
  java.awt.event.ActionEvent;
  java.awt.event.ActionListener;
}

class ButtonHandler <: ActionListener {
  public:
    def actionPerformed(event :ActionEvent) {
      val button: JButton = event.getSource() as JButton
      button.addActionListener(self)  // Reference to this instance
    }
}

this and self are only available in instance contexts; static methods and static fields cannot reference them.

Next Steps

Records

Records are concise immutable data classes with generated equals, hashCode, toString and copy:

record Point(x: Int, y: Int)

val p = new Point(1, 2)
p.x()                       // component access (methods)
p.copy(y = 9)               // partial copy with named arguments
p.copy()                    // full clone
p.copy(5, 6)                // positional copy

Records work with select pattern matching when combined with sealed interfaces, and take type parameters:

record Pair[A, B](first: A, second: B)

val p = new Pair[String, Integer]("gen", 9)
val (s, n) = p                 // destructuring declaration
p.copy(second = 42)            // named-argument copy

A record can also carry a { ... } body of methods — instance methods, static factories, private helpers, and operator methods — just like a class or enum. The methods see the generated component accessors:

record Fraction(num: Int, den: Int) {
public:
  static def of(n: Int, d: Int): Fraction {
    val g = gcd(Math::abs(n), d)
    return new Fraction(n / g, d / g)
  }
  def plus(o: Fraction): Fraction =         // backs the `+` operator
    Fraction::of(num() * o.den() + o.num() * den(), den() * o.den())
  def toDouble(): Double = (num() as Double) / (den() as Double)
private:
  static def gcd(a: Int, b: Int): Int { ... }
}

val third = Fraction::of(1, 3)
val one = third + third + third            // exactly 1/1

Generic Classes

Classes can take type parameters in []. A parameter is available throughout the body as an ordinary type:

class Box[T] {
  val v: T
public:
  def this(x: T) { v = x }
  def get(): T = v
}

When the expected type pins the type argument, the constructor infers it, so new Box(...) needs no [T] (the "diamond"). Explicit type arguments still work, and both forms are equivalent:

val b: Box[String] = new Box("x")           // T inferred as String
val n: Box[Integer] = new Box(9)            // T inferred as Integer
val b2: Box[String] = new Box[String]("y")  // explicit — same result

With no expected type to infer from, the bare generic is rejected — supply an expected type or explicit type arguments:

// val bad = new Box("x")   // ERROR E0066: raw generic type Box —
                            // write `new Box[String]("x")` or annotate the target

Type arguments are invariant (Box[Dog] is not a Box[Animal]); see Variables and Types.

Operator Overloading

Binary operators dispatch to convention methods on the left operand (Kotlin-style): a + b calls a.plus(b), and likewise -minus, *times, /div, %rem. Compound assignment (a += b) goes through the same method. + keeps string concatenation whenever a String is involved, and numeric operands keep primitive arithmetic.

class Vec {
  val x: Int
  val y: Int
public:
  def this(x: Int, y: Int) { this.x = x; this.y = y }
  def plus(o: Vec): Vec { return new Vec(this.x + o.x, this.y + o.y) }
  def times(k: Int): Vec { return new Vec(this.x * k, this.y * k) }
}

val v = new Vec(1, 2) + new Vec(3, 4)   // Vec(4, 6)
val w = new Vec(1, 2) * 3               // Vec(3, 6)

Enums

Enums compile to standard JVM enums. Constants get name() / ordinal(); values() and valueOf(String) work as in Java. Record-style parameters make data-carrying enums: each parameter becomes a final field with an accessor, and constants pass constructor arguments.

enum Color { RED, GREEN, BLUE }

enum Planet(mass: Double) {
  MERCURY(3.3e23),
  EARTH(5.97e24)
}

println("" + Planet::EARTH.mass())
foreach p: Planet in Planet::values() {
  println(p.name() + " = " + p.mass())
}
Planet::valueOf("EARTH")     // works with java.lang.Enum.valueOf

Enums can declare methods in access sections after the constant list — instance methods see the constant's data, static methods see values():

enum Planet(mass: Double) {
  MERCURY(3.3e23),
  EARTH(5.97e24)
public:
  def heavierThan(other: Planet): Boolean {
    return this.mass() > other.mass()
  }
}

Algebraic data types (case cases)

When the cases use the case keyword, each case can carry its own fields — the enum becomes a full sum-of-products, so an algebraic data type no longer needs a hand-written sealed interface plus records:

enum Shape {
  case Circle(radius: Double)
  case Square(side: Double)
  case Origin
public:
  def area(): Double = select this {
    case c is Circle: c.radius() * c.radius() * 3.14
    case s is Square: s.side() * s.side()
    case o is Origin: 0.0
  }
}

val c: Shape = new Circle(2.0)
c.area()                      // 12.56

Each product case (case Circle(radius: Double)) has typed fields with accessors; a singleton case (case Origin) is a zero-field case used as new Origin(). The enum desugars to a sealed interface with one record per case, so exhaustiveness (E0042) and select pattern matching come for free.

A case-style enum is a sealed hierarchy rather than a java.lang.Enum, so it does not get values()/valueOf()/ordinal() — use the plain constant form above when you want those.