Fields
Fields are how a node stores data. Because Stof is a JSON superset, plain JSON key-value pairs are already valid fields — everything below is what you can layer on top of that when you want more control.
Declaration Styles
JSON-style, shorthand, and explicitly typed declarations are all the same thing under the hood:
"json_style": "still valid"
shorthand: "no quotes needed"
str typed: "explicit type"
#[main]
fn main() {
pln(self.json_style, self.shorthand, self.typed);
}Types & Casting
A typed field keeps its type — assigning a new value casts it, rather than replacing the type:
str field: "temp"
#[main]
fn main() {
self.field = 42;
pln(self.field, typeof self.field);
}42 becomes the string "42" on assignment, because field was declared str.
Const Fields
const throws on write instead of silently accepting it:
const str locked: "permanent"
#[main]
fn main() {
try { self.locked = "changed"; }
catch { pln('blocked'); }
pln(self.locked);
}Access Modifiers
#[readonly] allows reads from anywhere but throws on write. #[private] restricts both — visible only to the object that defines it, not children, not parents:
Inner: {
#[private]
secret: 'hidden'
fn revealed() -> str {
self.secret
}
}
#[main]
fn main() {
pln(self.Inner.revealed());
pln(self.Inner.secret);
}revealed() runs on Inner itself, so it can see secret. main() is calling in from outside Inner, so the second pln prints null.
Non-Null Fields
A ! suffix on a type makes a field reject null; without one, any typed field can still hold null:
list! required: [1, 2, 3]
obj optional: null
#[main]
fn main() {
pln(self.required, self.optional);
}Assigning null to required would throw. Null & Initialization covers the rest of how nullability works, including ?? and null-safe access.
Union & Tuple Fields
A field can accept more than one type. Assigning a value outside the union casts it to the first matching type:
bool | int flexible: false
(str, ver) release: ('beta', 0.5.24)
#[main]
fn main() {
self.flexible = 'not in union';
pln(self.flexible, self.release);
}'not in union' isn't a bool or an int, so it gets cast to the first matching type — bool — rather than rejected.
Creating Fields Dynamically
Assigning to a path creates whatever doesn't exist yet along the way — fields, nodes/objects, even new roots:
#[main]
fn main() {
self.created = true;
pln(self.created);
root.NewSpace.nested = 'auto-created';
pln(root.NewSpace.nested);
}NewSpace didn't exist before this ran — assigning root.NewSpace.nested created both the node and the field in one step.