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()— callsthis.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.