Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Classes and construction

A class declares typed fields and associated methods. It has no inheritance, dynamic attributes, class variables, properties, or runtime method lookup.

class Point:
    x: i64
    y: i64

    def squared_length(self) -> Result[i64, OverflowError[i64]]:
        Ok(self.x * self.x + self.y * self.y)

Without __init__, the generated constructor takes fields in declaration order. Point(3, 4) and Point.new(3, 4) return Point directly. Construction itself allocates nothing; arguments and initializer operations retain their own error contracts. new is a reserved class member.

A class name without arguments is a function value with the constructor’s positional signature: make = Point lets make(3, 4) construct a point. A generic class needs an explicit instance, such as Cell[i64], or a concrete expected Callable result naming the class instance. Constructor values require all positional arguments; parameter names and defaults belong to the statically resolved constructor call. Taking the value allocates nothing and preserves constructor visibility and fallibility.

An explicit __init__ overrides the generated constructor and returns either () or Result[(), E]. The fallible form makes both constructor spellings and constructor function values return precisely Result[Class, E]. The declared error type is independent of the caller’s arithmetic mode; successful fallible initializers write explicit Ok(()). Non-unit Result success types are rejected. Storage itself allocates nothing, so construction does not add AllocError. Initializer operations and parameter defaults must declare or explicitly map their actual errors into E, including allocation failures when applicable. __del__ and __exit__ still require (); cleanup cannot propagate failure. Class parameters in an initializer output, including a parameter naming the whole Result, are substituted before validating that output. Unit class type arguments remain unsupported; a concrete unit alias can still name an initializer output in an otherwise valid generic class. Fields may contain classes, enums, tuples, collections, and resolved owned closures, but not references, generators, or unit.

Every successful initializer exit must initialize every field. Branches merge their definitely initialized fields; a loop alone cannot establish initialization because it may run zero times. Field assignments performed inside an if or match block used as a binding or assignment’s value do not establish initialization after that value block. Assign the selected value directly to self.field instead; the outer assignment initializes that field. See conditional values. Initialized fields may be read. Borrowing or passing the whole partially initialized instance is rejected, and initialization cannot be delegated to another method. Constructors accept named arguments, using field names for generated constructors and parameter names for explicit initializers. Explicit initializer parameter defaults are inherited by both constructor spellings. See call rules. Field defaults, constructor overloading, and static methods are unsupported.

Failed initialization destroys initialized fields and remaining owned arguments and skips the class’s destructor. The destructor becomes active only after successful initialization; see cleanup.

Classes can define arithmetic operator hooks such as __add__ and __neg__, compiled as direct method calls. Any other __name__ method besides __init__, __del__, __enter__ and __exit__ is rejected, so Python special methods such as __str__ and __len__ cannot be silently ignored.

Class identity is nominal. Structural equality compares fields without calling user methods. Printing uses Point(x=3, y=4) unless the class defines display or debug. Comparison and hashing hooks such as __eq__, __lt__ and __hash__ are rejected; see equality, ordering and hashing. Automatic equality and formatting reject recursive data. Instance moves, field access, and explicit duplication follow ownership; receiver signatures follow methods, and constructor access follows visibility.

A class pattern such as case Point(x, y=0): takes a class apart in match, written like the generated constructor with every field named once.

One-field classes

A class with exactly one field and no __del__ is represented as that field. Wrapping a value in such a class, reading its field, or moving the field out does not copy or allocate, so a wrapper such as class Handle: _file: File costs the same as the bare File. The field’s type is unrestricted: a scalar, str, a collection, an enum, or another class. Nothing in the source changes: the class keeps its name, methods, equality, and printing (Handle(_file=...)), and the field keeps its visibility. A one-field class with __del__ keeps a separate record because its cleanup state needs storage. See value representations.

A field whose type is another class stores that class’s fields inside the owner, at a fixed position. Reading path.start.x costs the same as reading a field of path itself, and moving or returning path copies its bytes once, with no per-field address work. This is not visible in source: nested fields still move, borrow, and run __del__ as other fields do.

Copy classes

@copy on the line before class declares a copy class. Its instances copy on ordinary value use, as copyable tuples and enums do: assignment, owned argument passing, return, collection insertion, capture, and by-value receivers (self: Point) all leave the source usable. Copying duplicates the inline fields and never allocates; string fields share immutable storage. copy(value) still accepts the instance. Other classes remain affine.

@copy
class Point:
    x: i64
    y: i64

def main() -> Result[(), IoError]:
    p = Point(1, 2)
    mut q = p
    q.x = 5
    print(p, q)?
    Ok(())
Point(x=1, y=2) Point(x=5, y=2)

The declaration is checked as a whole:

  • Every field must copy: a scalar, str, another @copy class, or a tuple or enum whose components copy. The error names the field and its type.
  • The class cannot define __del__, because each copy would run it.

A generic @copy class copies when its type arguments copy, as a tuple does. Pair[i64] and Pair[str] copy; Pair[list[i64]] is accepted and moves. The field rule is checked at the definition, whether or not the class is used: a field must copy whenever every type argument copies. T, (T, U), Option[T] and Pair[T] qualify; a field such as items: list[T] or b: Box[T] moves for every argument and is reported at its declaration. A CopyType bound (or IntType or FloatType) restricts the class to instances that copy:

@copy
class Pair[T]:
    left: T
    right: T

def main() -> Result[(), AllocError | IoError]:
    numbers = Pair(1, 2)
    copied = numbers
    lists = Pair([1]?, [2]?)
    moved = lists
    print(numbers, copied, moved)?
    Ok(())
Pair[i64](left=1, right=2) Pair[i64](left=1, right=2) Pair[list[i64]](left=[1], right=[2])

Equality, printing, field access, and matching are unchanged. Classes are collection keys when every field is a supported key type. Custom __del__ cleanup is allowed, including in nested fields; such keys cannot be copied for set algebra or key-view to_set(). Hashing reads their fields structurally, just as equality does. Ordinary instances move into the collection; @copy instances copy. __hash__ and __eq__ hooks are unsupported.

Annotations

An annotation is @name alone on a line directly before a declaration. The set is closed and compiler-known; there are no user decorators. The annotations are @copy, @newtype, and @redacted. @copy and @newtype apply only to class declarations, including pub class; @redacted also applies to enums. An unknown name, arguments, a repeated annotation, or an annotation before any other declaration, member, or statement is an error.

@newtype makes a class forward its field’s operators; see newtype forwarding. The class must declare exactly one field and cannot define __init__. It can have type parameters (@newtype class Wrapper[T]: value: T) and __del__.

@copy and @newtype are independent. @newtype does not imply @copy: a @newtype class over i64 still moves on assignment unless it is also @copy. Forwarded operators borrow their operands, so a + a and reusing a after a + b work either way. A class can carry both annotations, in either order, with the same meaning:

@newtype
@copy
class Seconds:
    value: f64

def main() -> Result[(), IoError]:
    a = Seconds(1.5)
    b = a
    print(a + b)?
    Ok(())
Seconds(value=3.0)

Neither annotation changes layout or printing. Every class with one field and no __del__ uses the one-field representation, with or without @newtype, and a @newtype value prints as its class (Seconds(value=3.0)). Annotation names follow @ and do not reserve words: copy remains an ordinary name, and the builtin copy(value) is unaffected.

Redacted types

@redacted on a class or enum hides its contents from every automatic formatting path: print, str.repr, f-string fields, and the report main writes for a returned error. The value renders as its type name followed by (@redacted), at any nesting depth: in a list, dictionary, tuple, Option, Result, enum payload, or another class’s field.

@redacted
class ApiKey:
    secret: str

class Client:
    host: str
    key: ApiKey

def main() -> Result[(), AllocError | IoError]:
    client = Client("example.com", ApiKey("hunter2"))
    print(client)?
    print([ApiKey("other")]?)?
    print(client.key.secret.byte_len())?
    Ok(())
Client(host="example.com", key=ApiKey(@redacted))
[ApiKey(@redacted)]
7

The rules:

  • Redaction applies to the whole value. There is no per-field form; a class that mixes secret and public data either is redacted entirely or keeps its secret in a separate redacted field type, as Client does above.
  • The name includes type arguments and module qualification as ordinary printing would: a generic Secret[T] prints Secret[i64](@redacted).
  • A generic wrapper over a redacted type is not itself redacted; Holder[ApiKey] prints Holder[ApiKey](item=ApiKey(@redacted)). A @newtype over a redacted type likewise prints its own name with the redacted field.
  • In an f-string, !r, !s, !a, =, and every format spec give the same ApiKey(@redacted); there is no conversion that reveals the contents. Padding and alignment apply to the marker.
  • Explicit formatting is unaffected: a display or debug method the type defines is used by print, str.repr and f-strings as for any other type, and a std.report Formattable implementation still writes what it chooses.
  • Field access, equality, hashing, ordering, matching, moves, and layout are unchanged. @redacted combines with @copy and @newtype in any order.

Redaction is a guard against accidental logging, not a security boundary: code that can read a field can still print it.