Structured data access
mut config = { server: { host: "localhost", port: 8080 }, features: ["auth", "cache"] }
print(config.server.port) # 8080
print(config.features[0]) # auth
config.server.port = 9090 # path assignment; root must be mutable
Keys, bare or quoted
A bare key must be an identifier - keywords are allowed, so x.type is fine. A quoted key can be anything, which is what makes HTTP headers and hyphenated JSON expressible inline:
http.response(303, "", { location: "/", "set-cookie": "a=b; HttpOnly" })
{ a: 1 } and { "a": 1 } are the same map. A quoted key is taken verbatim - {expr} does not interpolate in key position, because a map literal's shape is fixed at parse time. ecko fmt canonicalizes to the bare form where the key is a plain identifier.
Access is strict
xs[9] # index 9 is out of bounds (len 3)
m.key # map has no key 'key'
s.field # struct has no field 'field'
The nullable lookup is get:
get(m, "key") # null on a miss
get(xs, 9) # null on a miss
Reach for get when absence is expected, and indexing when it is not. That choice is the whole point: a strict read fails at the mistake, while a nullable read that silently returns null propagates the problem into unrelated code. Rule 1 of the error dialect is that only the documented nullable lookups return null.
match tests rather than accesses, so a non-matching pattern is never an error - which makes it the right tool for data of uncertain shape.
Membership with in
xs = [1, 2, 3]
print(2 in xs) # true
print("ell" in "hello") # true - substring
print("port" in { port: 80 }) # true - key
print(not (9 in xs)) # true - `not 9 in xs` means the same
item in coll gives the same answer as contains(coll, item): an equal element of a list, a substring of a string or bytes, a key of a map, an Int inside a range. Anything else, like 1 in 5, is an error rather than false.
Defaults with ??
a ?? b is a, unless a is null - then it is b. It is the other half of get: the lookup that may miss, and the value to use when it does.
settings = { level: "debug" }
level = get(settings, "level") ?? "info" # "debug"
port = get(settings, "port") ?? 8080 # 8080
zero = get({ n: 0 }, "n") ?? 5 # 0
Only null is replaced. 0, false and "" are values, not absence, which is the difference from or - or asks whether something is truthy and answers with a Bool. The right side runs only when it is needed, so get(cache, k) ?? fetch(k) fetches only on a miss.
It binds tighter than comparison and looser than arithmetic - get(m, "n") ?? 0 > 5 compares the defaulted value - and a chain takes the first value that is not null: a ?? b ?? c.
Combining maps
merge(a, b) is a new map with b's entries over a's. The right side wins a shared key, neither input changes, and a nested map is replaced rather than merged into.
defaults = { host: "localhost", port: 8080 }
config = merge(defaults, { port: 9090 }) # { host: "localhost", port: 9090 }
a + b on two maps is an error that names merge: which side should win a shared key is a choice worth writing down.
Slicing
Strings, lists and bytes slice with range syntax. Strings slice by character, so multi-byte UTF-8 is handled correctly:
s = "hello"
s[1..3] # "el" exclusive end
s[0..=2] # "hel" inclusive end
s[..3] # "hel" open start
s[2..] # "llo" open end
s[-3..] # "llo" negative from the end
s[1..-1] # "ell"
xs = [1, 2, 3, 4]
xs[1..3] # [2, 3]
Slices are total where indexing is strict: out-of-range bounds clamp (s[2..99] is "llo", s[7..9] is "") and a reversed range is empty. Slicing takes what is there and never errors.
That asymmetry is deliberate. xs[i] asks for one specific element, and its absence is a mistake. xs[a..b] asks for a region, and "less than you asked for" is a sensible answer.
..= requires an end index: s[0..=] is a parse error, and s[0..] is the open form.
Determinism
Maps print and JSON-encode with sorted keys, and map iteration yields pairs in sorted key order. Output is reproducible without sorting anything yourself.