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

Structural Objects

An object is a structural value containing named fields in source order. Field names, field types, and order participate in its structural type.

let Pair:type = [left:int64 right:int64]
let pair:Pair = [left=20 right=22]

A type alias names the structure; it does not create a runtime class object or give the structure nominal identity.

Structural Intersections

Intersecting object types combines their field requirements. Unique fields are retained; a field required by both sides receives the intersection of its two types.

const Located:type = [line:int64 column:int64]
const Labeled:type = [label:string]
const LabeledLocation:type = Located & Labeled

# equivalent requirements:
# [line:int64 column:int64 label:string]

Matching fields must have the same mutability. Choosing the stricter-looking declaration would be unsound: code accepting the mutable contract is allowed to write the field, while the const contract prohibits that write. Incompatible field types normalize the containing intersection to never.

Intersection never creates nominal identity. A structurally stronger alias remains the same nominal kind as any nominal component it already contains.

Fields and Mutation

Member access uses .. A mutable object binding permits assignment to its mutable fields. Ordinary object copies remain independent:

let original = [name="draft" saved=false]
let copy = original
copy.saved = true             # original.saved remains false

Nested array and object fields recursively follow the same value rule.

Immutable Records

const [...] in a type position is an immutable record: a runtime value whose contents never change after it is built. It is not a compile-time value, and it is not the const binding declaration: the binding may still be replaced whole. Nothing writes through such a value — not a field, not a member changed in place (an array's push, a dictionary's store or pop), not a place taken of a field (@info.radix), and not a method of the record that assigns a field (refused where the method is declared). The barrier holds through copies, containers, unions, and function boundaries, because the qualifier is part of the type: const [x:int64] and [x:int64] are different types. A writable record of the same shape may be used where the immutable one is expected (the value is copied there), never the reverse, so a writable contract cannot be handed an immutable value. A copy of a member taken out with let is an ordinary value again. type of any & const [...] mints an immutable nominal type, and a child of an immutable parent is immutable.

What cannot change stays proven, which is what the qualifier is for: a field of an immutable record may relate to an earlier sibling — radix:uint8<radix =? alphabet.length> — and the relation is checked when the record is built (for the default and for an explicit value; a wrong explicit value is refuted) and known wherever the record is read afterwards. A writable record refuses such an invariant: either field could be assigned later.

BaseInfo:type = const [
    alphabet:string<2 <=? length <=? uint8.max>
    case_sensitive:bool
    radix:uint8<radix =? alphabet.length> = alphabet.length     # a sibling invariant, with its default
]
let hex = BaseInfo['0123456789abcdef' false]                   # radix defaults to 16, proven equal to the length
let bin = BaseInfo['01' true 2]                                # an explicit radix is checked the same way
let last_digit = (info:BaseInfo):>string => info.alphabet[info.radix - 1]   # in bounds: radix is the alphabet's length
let main = ():>int64 => {
    let current:BaseInfo = bin
    current = hex                                              # the binding is replaced whole; its contents never change
    if last_digit(current) =? 'f' and bin.radix =? 2 return 0
    return 1
}

info.radix = 8, info.alphabet = "01", or bump(@info.radix) in last_digit would each be refused as a write through an immutable record; BaseInfo['01' true 3] is refuted at construction.

Constructors

Calling an object type constructs a value of it. The field list is the constructor's signature, read exactly like a function's: positional arguments fill fields in declaration order, keyword arguments name them, and a field declared with a default (name:type = default) may be left out — a default may refer to earlier fields by name.

Other names in a default come from the scope where the type was declared, including that module's namespace imports. Importing the type does not require repeating those imports. Earlier fields use the values supplied to this particular construction; explicit arguments are evaluated in the caller's scope.

let Span:type = [start:int64 stop:int64 = start label:string = "span"]

let a = Span(1 9)                      # positional
let b = Span(stop=5 start=2 label="b") # keywords, in any order
let c = Span(7)                        # stop = start, label = "span"

The call is checked as the object literal [start=1 stop=9 label="span"] against the type: an unknown field, a field given twice, too many positional arguments, or a missing field without a default is an error. Types are values, so in a value context the name is the constructor and in a type context it is the type, with no separate class declaration.

Construction that needs more than filling fields is an ordinary function added to the type's constructor overload set with &=; a call dispatches over the field-wise signature and the overloads by the usual most-specific rule, so keyword-only parameters, validation, and error-value results live where functions already have them:

let Range:type = [start:int64 stop:int64]
Range &= (text:string):>Range => Range(0 text.length)

let a = Range(1 9)          # field-wise
let b = Range("seven..")    # the overload

A constructor can also be an ordinary function returning an object:

let make_pair = (left:int64 right:int64):>Pair =>
    [left=left right=right]

Positional Literals

Where an object type is expected — an annotation, an element of array<Point>, a dictionary's value — an object literal may give its fields positionally, in declaration order, exactly as a constructor call would; a field left out takes its default:

let Point:type = [x:int64 y:int64 = 0]
let Spec:type = [digits:set<string> case_insensitive:bool]

const specs:dict<string Spec> = [
    'b' -> [set'01' false]
    't' -> [set'012' false]
]

let main = ():>int64 => {
    let p:Point = [3]                        # [x=3 y=0]
    let corners:array<Point> = [[1 2] [5 6]]
    return p.x + corners[1].y + specs['t'].digits.length   # 3 + 6 + 3
}

Mixing named and positional items is not allowed; too many items, or a missing field without a default, is an error naming the field.

Methods

An object type may declare methods: name = (params) => body rows among the fields. Inside a method, bare names of the type's fields and methods refer to the instance (stop - start, width); a method that assigns or grows a field takes its receiver as a place, so it must be called on a binding or a field, not on a temporary. Calls are value.method(args), and a zero-argument method is called by value.method alone.

let Span:type = [
    start:int64
    stop:int64 = start
    width = () => stop - start
    grow = (by:int64) => { stop += by }
    shifted = (by:int64):>Span => Span(start + by stop + by)
]

let main = ():>int64 => {
    let s = Span(3 7)
    s.grow(2)                       # 3..9
    return s.width + s.shifted(1).start   # 6 + 4
}

Methods are compiled as ordinary functions taking the instance first as a hidden parameter — there is no self; a body reaches its instance only through bare field and method names — so no function value is stored in the object; a method that reads no field (nor calls one that does) is static and takes no instance at all (see Types as Values); they are not values yet (s.grow without a call is an error). Methods and constructor overloads are declared on module-level types only.

A structural or hybrid type can also contextually construct an object literal:

const ContextError:type =
    (type of error) & [context:string code:int64]

let problem = ContextError[
    context='request body'
    code=400
]

The fields are checked against the structural portion, and the resulting value carries the type's nominal ancestry. A structurally strengthened alias requires all fields from the combined intersection.

Function Fields

A function field may use sibling fields from the object literal's scope:

let counter = (start:int64=0) => [
    value = start
    increment = () => (value += 1)
]

Accessing a zero-argument function field calls it when that call is valid. Explicit () remains available.

Extracting a method as a stored naked function, escaping captures, and full function-handle identity depend on the provisional function-handle and closure design.

Places Through Fields

@object.field selects the place occupied by the field at the end of the complete route. Although the parser groups the prefix first, the language does not expose an intermediate reference value for object. @(object.field) selects the same place. There is no separate object.@field syntax.

See Values, Copies, and Places for aliasing and overlap rules.

Recursive Objects

An object type may contain itself through a union-typed field: let Node:type = [value:int64 next:Node|none]. The self-referencing member is held behind a handle, copies are deep, and is? narrows the field route (node.next is? Node) so the field can be read and assigned as a Node. See Recursive Types.