Classes¶
A class is Incan’s behavior-first type: it models an object with methods, mutable state, and inheritance.
If you’re deciding between model and class, start with the Models & classes overview. This page focuses on how classes behave once you’ve chosen them.
Quick start¶
class Counter:
value: int # (1)
def get(self) -> int: # (2)
return self.value
def increment(mut self) -> None: # (3)
self.value += 1
def main() -> None:
c = Counter(value=0) # (4)
c.increment()
println(c.get()) # (5)
println(c.value) # (6)
- Fields define object state. See Defining fields.
- Read-only methods take
self. See Methods and receivers (selfvsmut self). - Mutating methods take
mut self. See Methods and receivers (selfvsmut self). - Construction is keyword-only (named arguments). See Constructing a class.
- Running a method on an object returns the result of the method. See Methods and receivers (
selfvsmut self). - Member access uses canonical field names. See Field access.
The key ideas:
- A
classis behavior-first: methods are the primary API. - A
classcan be stateful: usemut selffor methods that modify fields. - A
classcan inherit: single inheritance withextendsfor behavior reuse and method overrides. - Schema mapping is not a class feature: classes don’t support field aliases/metadata; use a model for wire mapping.
Glossary
- field: a named piece of state stored on the object (e.g.
value: int) - method: a function defined on the class (e.g.
def increment(...)) - receiver: the first parameter of a method (
selformut self) - override: redefining a method in a child class so the child version is used
- composition: building larger types by putting one value inside another (e.g. a class holding a model)
Coming from Rust?
In the current backend, Incan classes lower to Rust structs + impls, but the surface syntax is closer to Python.
def m(self)corresponds to an immutable receiver (roughly like&self)def m(mut self)corresponds to a mutable receiver (roughly like&mut self)extendsis compile-time reuse + explicit overrides; it does not introduce subtyping
Coming from Python?
selfworks like Python’sself.mut selfis explicit: methods that modify fields must takemut self.- If you’re looking for Pydantic-like “data models” (DTOs, wire formats, schema mapping), prefer
model.
Coming from TypeScript / JavaScript?
selfis the object (roughly like JS/TSthis).- Construction is keyword-only (
Point(x=..., y=...)); there is noconstructor(...)method. extendsdoes not introduce subtyping (a child value can’t be used where the parent type is required); use traits/enums for polymorphism.
Defining fields¶
A class declares object state as typed fields.
- Syntax:
name: Type - Public field syntax:
pub name: Type - Optional defaults:
name: Type = expr
Fields are private by default, even when the class itself is pub. A private field may be accessed from methods on the declaring class, but external code and child classes must use pub fields or methods.
Field metadata and aliases ([alias="..."], [description="..."], or as "...") are not supported on classes.
pub class UserService:
repo: UserRepository
pub logger_name: str
def repository(self) -> UserRepository:
return self.repo
Constructing a class¶
Class construction is keyword-only (named arguments).
class Point:
x: int
y: int
def main() -> None:
p = Point(x=10, y=20)
This keeps call sites explicit and stable as you add/reorder fields.
Constructor keys are the declared field names (including inherited fields).
Coming from Python?
In Incan you don’t write an __init__/init method for classes; the declared fields (including inherited fields) define the constructor keys.
Rules:
- No positional args:
Point(10, 20)is not supported. - Unknown fields are errors:
Point(z=1)is a type error. - Duplicates are errors:
Point(x=1, x=2)is a type error. - Missing required fields are errors: if a field has no default, you must pass it.
Importing a public class does not make its private fields readable. Construction still follows the declaring provider's complete field order so private defaults are applied by that provider, including through direct imports, aliases, and multi-hop public facade re-exports. A later value.private_field access remains a type error outside methods on the declaring class.
Field access¶
Access a field with dot syntax:
def main() -> None:
c = Counter(value=0)
println(c.value)
Because classes don’t support field aliases/metadata (the way a model would), member access always uses the canonical field name. Private fields can only be read or written from methods on the declaring class; use pub for fields that are part of the public object surface.
For derived field-like values, use a computed property:
class Counter:
value: int
property doubled -> int:
return self.value * 2
def demo(c: Counter) -> int:
return c.doubled
Computed properties read like fields but execute a body on each read. See: Computed properties.
Methods and receivers (self vs mut self)¶
Use self for read-only methods and mut self for methods that modify the object.
class Counter:
value: int
def get(self) -> int:
return self.value
def increment(mut self) -> None:
self.value += 1
Static methods (@staticmethod)¶
A static method belongs to the type rather than an instance — it has no self receiver.
class Temperature:
celsius: float
@staticmethod
def from_fahrenheit(f: float) -> Temperature:
return Temperature(celsius=(f - 32.0) / 1.8)
def main() -> None:
t = Temperature.from_fahrenheit(98.6)
println(t.celsius)
Applies to: methods on class, model, enum, and newtype declarations.
Rules:
- A
@staticmethodmethod must not have aselformut selfparameter. The compiler rejects this. - Call static methods via the type name:
TypeName.method_name(...). - Generic static factory methods can return
Self; call them with explicit type arguments when inference needs the owner type, such asBox[int].make(...). @staticmethodcan be combined with@rust.externfor Rust-backed static methods on types.
Use cases:
- Factory methods: alternative constructors (
from_fahrenheit,from_json,parse). - Utility functions: logic scoped to a type that doesn't need instance state.
- Rust interop:
@rust.externon type methods requires@staticmethod(instance delegation is not supported).
Generic static factories can use the declaring type constructor directly:
class Box[T with Clone]:
value: T
@staticmethod
def make(value: T) -> Self:
return Box(value=value)
def main() -> None:
boxed = Box[int].make(1)
println(str(boxed.value))
Another example — a utility method that doesn't need instance state:
class MathUtils:
precision: int = 2
@staticmethod
def clamp(value: int, low: int, high: int) -> int:
if value < low:
return low
if value > high:
return high
return value
def main() -> None:
println(MathUtils.clamp(15, 0, 10)) # 10
See also: Decorators reference
Inheritance (extends) and overrides¶
Classes support single inheritance:
class Animal:
name: str
def speak(self) -> str:
return "..."
class Dog extends Animal:
breed: str
def speak(self) -> str:
return "Woof!"
A child class constructor includes inherited fields:
def main() -> None:
d = Dog(name="Rex", breed="Labrador") # `name` comes from `Animal`
println(d.speak())
Notes:
- Inheritance is for behavior reuse and method overrides.
extendsdoes not introduce subtyping: you cannot use aDogvalue where anAnimalvalue is required.- Overrides are explicit: if you define a method with the same name in a child class, it overrides the parent method.
If you only need a larger data shape, prefer composition with models. See Using models inside classes.
Trait composition (with ...)¶
Both a class and a model can implement traits:
trait Loggable:
def log(self, msg: str) -> None: ...
class Service with Loggable:
name: str
def log(self, msg: str) -> None:
println(f"[{self.name}] {msg}")
Traits are behavior-only (no storage). A trait default method may assume certain fields exist on the adopter; use @requires(...) to declare that contract in a trait.
See:
Using models inside classes (common pattern)¶
This is composition: it’s common to use models for data and classes for behavior.
A simple example:
model AnimalData:
name: str
species: str
class AnimalService:
data: AnimalData
def describe(self) -> str:
return f"{self.data.name} ({self.data.species})"
A more complex example:
from std.serde import json
@derive(json)
model User:
id: int
email: str
class UserService:
users: list[User]
def get_emails(self) -> list[str]:
return [u.email for u in self.users]
def main() -> None:
# defining the users (models)
alice = User(id=1, email="alice@example.com")
bob = User(id=2, email="bob@example.com")
zephod = User(id=42, email="zephod@example.com")
# defining the service (class)
service = UserService(users=[alice, bob, zephod])
println(service.get_emails()) # ["alice@example.com", "bob@example.com", "zephod@example.com"]
Reflection helpers¶
Classes provide:
__class_name__() -> str__fields__() -> FrozenList[FieldInfo]
Unlike models, classes do not support per-field aliases/metadata, so FieldInfo.alias and FieldInfo.description are always None and FieldInfo.wire_name == FieldInfo.name.
Common pitfalls¶
- Using a class for schema mapping: if you need wire keys/aliases, use a model (and embed it in a class if needed).
- Forgetting
mut self: if a method assigns toself.field, it must takemut self. - Using inheritance for data reuse: prefer composition for data shapes; use
extendsfor behavior reuse and overrides.