ESC
Type to search...
S
Soli Docs

Classes & OOP

Object-oriented programming in Soli: classes, inheritance, interfaces, and static members.

Tip: Use < as an alias for extends (e.g., class Dog < Animal).

Basic Class Definition

class

Defines a class with properties and methods.

class Person
  name: String
  age: Int
  email: String

  new(name: String, age: Int, email: String = null)
    this.name = name
    this.age = age
    this.email = email ?? ""
  end

  def greet -> String
    "Hello, I'm " + this.name
  end

  def introduce -> String
    intro = "Hi, I'm " + this.name + " and I'm " + str(this.age) + " years old"
    if this.email != ""
      intro = intro + ". You can reach me at " + this.email
    end
    intro
  end

  def have_birthday
    this.age = this.age + 1
  end
end

Visibility Modifiers

Modifier Access
public Accessible from anywhere (default)
private Intended for use only within the class
protected Intended for use within class and subclasses

Note: Visibility modifiers are currently parsed for documentation purposes but not enforced at runtime.

Private Method Convention

In addition to the private keyword, Soli follows a naming convention where methods starting with an underscore (_) are considered internal/private helpers:

class PostsController < Controller
  # Underscore prefix marks internal helper methods
  def _permit_params(params: Any)
    {
      "title": params["title"],
      "content": params["content"]
    }
  end

  def create(req: Any)
    # Using the private helper
    permitted = this._permit_params(req["params"])
    Posts.create(permitted)
  end
end

Recommendation: Use either private def keyword OR underscore prefix (_) for internal helper methods. The underscore convention is used throughout the codebase (e.g., _authenticate, _build_post_params in examples).

class BankAccount
  public account_number: String
  private balance: Float

  new(account_number: String, initial_deposit: Float)
    this.account_number = account_number
    this.balance = initial_deposit
  end

  public def deposit(amount: Float) -> Bool
    if this.validate_amount(amount)
      this.balance = this.balance + amount
      return true
    end
    return false
  end
end

account = new BankAccount("123456789", 1000.0)
account.deposit(500.0)           # Works - public method
print(account.get_balance())     # 1500.0

Static Members

static

Properties and methods that belong to the class, not instances.

class MathUtils
  static PI: Float = 3.14159265359
  static E: Float = 2.71828182846

  static def square(x: Float) -> Float
    x * x
  end

  static def cube(x: Float) -> Float
    x * x * x
  end

  static def max(a: Float, b: Float) -> Float
    return a if a > b
    b
  end

  static def clamp(value: Float, min_val: Float, max_val: Float) -> Float
    return min_val if value < min_val
    return max_val if value > max_val
    value
  end
end

# Using static members
print(MathUtils.PI)           # 3.14159265359
print(MathUtils.square(4.0))  # 16.0
print(MathUtils.cube(3.0))    # 27.0
print(MathUtils.clamp(150, 0, 100))  # 100
def self.method_name

Ruby-idiomatic shorthand: prefix the method name with self. instead of using the static modifier. Reads naturally and skips the surrounding class << self block when you only have a method or two.

class MathUtils
  def self.square(x: Float) -> Float
    x * x
  end

  def self.cube(x: Float) -> Float
    x * x * x
  end
end

print(MathUtils.square(4.0))  # 16.0
print(MathUtils.cube(3.0))    # 27.0

fn self.foo works the same way (def and fn are interchangeable). Combining both — static def self.foo — is allowed and stays static.

class << self

Ruby-style singleton-class block: every method declared inside is treated as static, so you don't have to repeat the static modifier on each one. def and fn are interchangeable.

class MathUtils
  class << self
    def square(x: Float) -> Float
      x * x
    end

    def cube(x: Float) -> Float
      x * x * x
    end

    def max(a: Float, b: Float) -> Float
      a > b ? a : b
    end
  end
end

print(MathUtils.square(4.0))   # 16.0
print(MathUtils.cube(3.0))     # 27.0
print(MathUtils.max(2.0, 7.0)) # 7.0

The block can sit anywhere in the class body and coexists with regular instance methods, top-level static fn declarations, and static fields. Only method declarations are allowed inside it — fields and constants stay at the class top level (static foo: Type = ...). For just a single class method, the lighter def self.foo form (above) is usually clearer than wrapping a one-method block.

this and super

Reference to the current instance.

class User
  new(name)
    this.name = name
  end

  def say_hello
    println("Hello, " + this.name)
  end
end

self is an alias for this — they refer to the same instance and are interchangeable everywhere (instance methods, constructors, instance_eval blocks). Pick whichever reads better; Ruby refugees can keep typing self.

class User
  new(name)
    self.name = name        # same as this.name = name
  end

  def say_hello
    println("Hello, " + self.name)
  end
end

Inheritance with super.

class Admin < User
  def say_hello
    super.say_hello()
    println("I am an admin.")
  end
end

Static methods.

class Math
  static def add(a, b)
    a + b
  end
end

The @ Sigil

Inside any instance method, @name is shorthand for this.name. Reads, writes, compound assignment, method calls, and chained access all work.

class Counter
  n: Int

  new()
    @n = 0          # same as this.n = 0
  end

  def bump
    @n += 1         # read + write via sugar
  end

  def value -> Int
    @n              # bare read, same as this.n
  end
end

Supported forms:

  • @foo — read
  • @foo = x — write
  • @foo += 1 — compound assignment (also -=, *=, /=, %=)
  • @foo() — calls this.foo()
  • @foo.bar, @items[0] — chained access and indexing
  • @foo++, @foo-- — postfix increment/decrement

Not supported: @@foo (Ruby-style class variables) is rejected at parse time — use a static field instead. And @foo outside a class method fails the same way this.foo would, because this isn't in scope.

In controllers: fields set on the controller instance during an action (via @foo = ... or this.foo = ...) are automatically exposed as view locals in the template that action renders — no data hash needed.

class PostsController < Controller
  def show
    @post = Post.find(params["id"])
    @comments = Comment.where({"post_id": @post.id}).all
    render("posts/show")    # view sees `post` and `comments`
  end
end

Explicit render("view", {...}) data always wins over auto-exposed fields. Framework fields (req, params, session, headers) are never re-exposed this way. Auto-exposure is scoped to the action currently running.

Multi-level Inheritance

Classes can extend other user-defined classes, forming deep inheritance chains. Methods, fields, constructors, and static methods are all inherited through the full chain.

class Animal
  name: String

  new(name: String)
    this.name = name
  end

  def speak -> String
    "..."
  end
end

class Pet < Animal
  owner: String

  new(name: String, owner: String)
    super(name)
    this.owner = owner
  end

  def describe -> String
    this.name + " belongs to " + this.owner
  end
end

class Dog < Pet
  def speak -> String
    "Woof!"
  end
end

dog = new Dog("Rex", "Alice")
print(dog.speak())     # "Woof!"
print(dog.describe())  # "Rex belongs to Alice"
print(dog.name)        # "Rex" (inherited from Animal)

Super Chaining

Each class's super refers to its direct parent, allowing calls to chain up through the hierarchy.

class A
  def identify -> String
    "A"
  end
end

class B < A
  def identify -> String
    super.identify() + " -> B"
  end
end

class C < B
  def identify -> String
    super.identify() + " -> C"
  end
end

print(new C().identify())  # "A -> B -> C"

Nested Classes

Classes can be defined inside other classes, providing logical grouping and access to the outer class's members.

class Outer
  outer_value: Int

  new(value: Int)
    this.outer_value = value
  end

  def create_inner(x: Int) -> Inner
    new Inner(this, x)
  end

  class Inner
    outer: Outer
    inner_value: Int

    new(outer: Outer, value: Int)
      this.outer = outer
      this.inner_value = value
    end

    def get_combined -> Int
      this.outer.outer_value + this.inner_value
    end

    def get_outer_value -> Int
      this.outer.outer_value
    end
  end
end

# Using nested classes
outer = new Outer(10)
inner = new Outer::Inner(outer, 5)
print(inner.get_combined())   # 15
print(inner.get_outer_value()) # 10

# Factory pattern
inner2 = outer.create_inner(20)
print(inner2.get_combined())   # 30

Accessing Nested Classes

Nested classes can be accessed through the outer class using dot notation.

class Tree
  class Node
    value: Int
    left: Node?
    right: Node?

    new(value: Int)
      this.value = value
      this.left = null
      this.right = null
    end

    def insert(new_value: Int)
      if new_value < this.value
        if this.left == null
          this.left = new Tree::Node(new_value)
        else
          this.left.insert(new_value)
        end
      else
        if this.right == null
          this.right = new Tree::Node(new_value)
        else
          this.right.insert(new_value)
        end
      end
    end
  end

  root: Tree::Node?

  new
    this.root = null
  end

  def insert(value: Int)
    if this.root == null
      this.root = new Tree::Node(value)
    else
      this.root.insert(value)
    end
  end
end

tree = new Tree()
tree.insert(5)
tree.insert(3)
tree.insert(7)

Domain-Driven Naming Convention

Nested classes with the :: separator follow a domain-driven naming convention, commonly used to organize related classes into logical namespaces. This pattern groups related functionality under a domain or context.

# Domain model organization
class User
  class Profile
    username: String
    avatar_url: String

    new(username: String)
      this.username = username
      this.avatar_url = "https://example.com/avatars/" + username
    end
  end

  class Settings
    theme: String
    notifications: Bool

    new
      this.theme = "light"
      this.notifications = true
    end
  end
end

profile = new User::Profile("alice")
settings = new User::Settings()

# Controller action organization
class Posts
  class Action
    def create(title: String, content: String)
      "Creating post: " + title
    end

    def delete(id: Int)
      "Deleting post: " + str(id)
    end
  end

  class Validator
    def validate_post(post: Any) -> Bool
      post["title"] != null && post["content"] != null
    end
  end
end

action = new Posts::Action()
action.create("Hello", "World")

Fully Qualified Names

Nested classes can be accessed using fully qualified names from anywhere in the code.

class Service
  class Database
    def query(sql: String)
      "Executing: " + sql
    end
  end

  class Cache
    def get(key: String) -> String?
      return null
    end
  end
end

# Accessing from any scope using fully qualified names
db = new Service::Database()
cache = new Service::Cache()
print(db.query("SELECT * FROM users"))
print(cache.get("session:123"))

Interfaces

Interfaces define contracts that classes must implement.

interface Drawable
  def draw -> String
  def get_color -> String
end

class Circle implements Drawable
  radius: Float
  color: String

  new(radius: Float, color: String)
    this.radius = radius
    this.color = color
  end

  def draw -> String
    "Circle with radius " + str(this.radius)
  end

  def get_color -> String
    this.color
  end
end

Multiple Interfaces

A class can implement multiple interfaces.

interface Printable
  def print
end

interface Exportable
  def export -> String
end

class Report implements Printable, Exportable
  def print
    print("Printing report...")
  end

  def export -> String
    "PDF"
  end
end
~

~ is an alias for implements in class headers, in the same spirit as < aliases extends. Composes with both forms — use whichever reads best.

# Shorthand on its own
class Circle ~ Drawable, Resizable
  # ...
end

# `<` + `~`
class Dog < Animal ~ Greetable
  def greet "woof" end
end

class Cat < Animal ~ Greetable
  def greet "meow" end
end

The full implements keyword still works; pick whichever matches the style of the surrounding code.